umpordez 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -198,6 +198,20 @@ umpordez --version check version
198
198
  | Uploads | AWS S3 with signed URLs |
199
199
  | Email | Nodemailer + HTML templates |
200
200
 
201
+ ## What changed in 1.2
202
+
203
+ - **Non-Negotiables** section in AGENTS.md — codifies rules per layer
204
+ (server, admin UI, site, mobile) so teams don't have to rediscover
205
+ them.
206
+ - **Key Patterns (TL;DR)** — quick reference for adding entities,
207
+ context DI, route handlers, and transactions.
208
+ - **i18n Coverage** table — shows which layers speak which
209
+ languages at a glance.
210
+ - **Anonymous-first mobile** — synthetic device-account on first
211
+ boot, never gate behind sign-in.
212
+ - **fetch-api** now supports PUT and PATCH methods plus safer
213
+ URL parameter filtering (skips null/undefined values).
214
+
201
215
  ## What changed in 1.1
202
216
 
203
217
  The big one. v1.0.x was server + web only. v1.1 adds:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "umpordez",
3
3
  "type": "module",
4
- "version": "1.1.0",
4
+ "version": "1.2.1",
5
5
  "description": "SaaS starter kit generator — server, admin SPA, public site (i18n), mobile app (offline-first, IAP, native push)",
6
6
  "main": "cli.mjs",
7
7
  "scripts": {
@@ -276,7 +276,86 @@ const ok = await confirm({
276
276
  if (!ok) return;
277
277
  ```
278
278
 
279
- ## Multi-Tenancy
279
+ ## Non-Negotiables
280
+
281
+ ### Code Style
282
+ - 4-space indent, semicolons, max 80 cols, LF, always braces
283
+ - TypeScript strict (no `any`, use `unknown`)
284
+ - Code in English; user-facing strings in Portuguese (pt-BR)
285
+ - Path aliases: `#core/*`, `#shared/*`, `#api/*` (server), `@/*` (admin UI / mobile)
286
+ - No relative paths beyond single `../`
287
+
288
+ ### Server
289
+ - Routes go through models (never `req.context.knex()` in routes)
290
+ - `buildHandler()` wraps every async handler; Zod validates input
291
+ - Raw SQL in migrations (`knex.schema.raw()`)
292
+ - Models extend `BaseModel` — get `this.knex`, `this.context`, `this.db`
293
+ - Errors: `ValidationError` (400), `NotFoundError` (404), `ConflictError` (409), `ForbiddenError` (403)
294
+ - Pagination: `{ rows, total, page, limit, totalPages }`
295
+ - Every table: `id UUID`, `utc_created_on`, `utc_updated_on`; multi-tenant tables also `account_id`
296
+
297
+ ### Admin UI
298
+ - `fetchApi()` for every API call (handles cookies, JSON, FormData)
299
+ - React Query for all server state; `keepPreviousData` on paginated queries
300
+ - Query keys centralized in `lib/query-keys.ts`
301
+ - shadcn/ui primitives under `components/ui/` (compose, don't fork)
302
+ - `useConfirm()` instead of `window.confirm()`, `toast` from sonner
303
+
304
+ ### Site
305
+ - Every string through `t()` — no bare text in templates
306
+ - URL strategy: `/` → pt-BR, `/en/...` → English (middleware strips prefix)
307
+ - Hreflang alternates + canonical + sitemap.xml on every page
308
+
309
+ ### Mobile
310
+ - **Free-first**: never gate behind sign-in; anonymous usage is the default
311
+ - **Anonymous-first**: synthetic UUID device-account on first boot, never gate behind login
312
+ - **Services own data access** — pages never call `getDb()` directly
313
+ - **User-scoped writes check `demandSigned()`** — reads always work, writes no-op when gate closed
314
+ - Every string through `t()` — no bare text in JSX
315
+ - NitroModules crash in Expo Go — lazy-load with `try/require`
316
+ - Never `expo prebuild --clean` (wipes native code; use `scripts/prebuild.sh`)
317
+
318
+ ### i18n Coverage
319
+ | Layer | pt-BR | en | Additional |
320
+ |-------|-------|----|------------|
321
+ | Server | ✓ | ✓ | Extensible via i18next |
322
+ | Admin UI | ✓ | ✗ | pt-BR only (Brazil market) |
323
+ | Site | ✓ | ✓ | URL-prefixed (/ vs /en/) |
324
+ | Mobile | ✓ | ✓ | Extensible per project |
325
+
326
+ ## Key Patterns (TL;DR)
327
+
328
+ ### Adding a domain entity
329
+ 1. `migrations/YYYYMMDDHHMMSS_create-thing.ts` — raw SQL
330
+ 2. `core/models/thing.ts` — extends `BaseModel`
331
+ 3. Register on `Context` in `core/context.ts`
332
+ 4. `apps/api/routes/thing.ts` — `buildHandler()` + Zod
333
+ 5. Admin UI: page + `hooks/queries/use-thing.ts`
334
+ 6. Mobile (optional): `src/services/thing/`
335
+
336
+ ### Context DI
337
+ ```typescript
338
+ const users = await req.context.user.list({ page: 1 });
339
+ this.user = new UserModel(this); // in context.ts
340
+ ```
341
+
342
+ ### Route handler
343
+ ```typescript
344
+ router.post('/things', buildHandler(async (req, res) => {
345
+ const data = z.object({ name: z.string().min(1) }).parse(req.body);
346
+ const result = await req.context.thing.create(req.accountId!, data);
347
+ res.json({ ok: true, result });
348
+ }));
349
+ ```
350
+
351
+ ### Transaction
352
+ ```typescript
353
+ await req.context.knexTransaction(async () => {
354
+ const user = await req.context.user.create(data);
355
+ await req.context.account.addMember(accountId, { ... });
356
+ });
357
+ ```
358
+
280
359
 
281
360
  ```
282
361
  users (global identity) → user_in_accounts (many-to-many + role) → accounts (tenant) → [domain entities]
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { AppState } from 'react-native';
3
3
  import { SafeAreaProvider } from 'react-native-safe-area-context';
4
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
4
5
  import Toast from 'react-native-toast-message';
5
6
  import * as SplashScreen from 'expo-splash-screen';
6
7
 
@@ -28,6 +29,13 @@ export default function App() {
28
29
  // `splashDone` flips, and screens read from SQLite which
29
30
  // serves cached data while the network sync runs.
30
31
  SplashScreen.hideAsync().catch(() => { /* best-effort */ });
32
+
33
+ // Force-hide the splash after 5s no matter what, so a stuck
34
+ // animation never keeps the user out of the app. In Release
35
+ // there's no red box — without this timeout a render-time
36
+ // crash is invisible.
37
+ const fallback = setTimeout(() => setSplashDone(true), 5000);
38
+
31
39
  (async () => {
32
40
  try {
33
41
  await initDb();
@@ -55,21 +63,26 @@ export default function App() {
55
63
  });
56
64
  }
57
65
  });
58
- return () => sub.remove();
66
+ return () => {
67
+ sub.remove();
68
+ clearTimeout(fallback);
69
+ };
59
70
  }, []);
60
71
 
61
72
  return (
62
- <SafeAreaProvider>
63
- <ThemeProvider>
64
- <AuthProvider>
65
- <Routes />
66
- <Toast config={toastConfig} />
67
- </AuthProvider>
68
- </ThemeProvider>
73
+ <GestureHandlerRootView style={{ flex: 1 }}>
74
+ <SafeAreaProvider>
75
+ <ThemeProvider>
76
+ <AuthProvider>
77
+ <Routes />
78
+ <Toast config={toastConfig} />
79
+ </AuthProvider>
80
+ </ThemeProvider>
69
81
 
70
- {!splashDone && (
71
- <AnimatedSplash onDone={() => setSplashDone(true)} />
72
- )}
73
- </SafeAreaProvider>
82
+ {!splashDone && (
83
+ <AnimatedSplash onDone={() => setSplashDone(true)} />
84
+ )}
85
+ </SafeAreaProvider>
86
+ </GestureHandlerRootView>
74
87
  );
75
88
  }
@@ -63,10 +63,17 @@ hasCompletedOnboarding
63
63
  render an "auth required" screen at the entry point. Wrap
64
64
  premium features in `<PremiumGate onUpgrade={openAuth} />`.
65
65
 
66
- - **Routes go through models** same rule as server. Mobile
66
+ - **Anonymous-first.** `AuthContext` fabricates a synthetic device-
67
+ account on first boot (`services/auth/anonymous.ts` returns a
68
+ UUID v4 stored in AsyncStorage). `signed: true` always;
69
+ `isAnonymous: true` for the default state. Never gate app entry
70
+ behind sign-in. The opt-in AuthStack lives in Profile's footer
71
+ link only.
72
+
73
+ - **Services own data access** — same rule as server. Mobile
67
74
  doesn't have models; the equivalent is: **services own data
68
- access**. Pages call `getContentByType('post')`, not
69
- `getDb().getAllAsync(...)`. New SQLite reads/writes go in the
75
+ access**. Pages call `services/crops`, `services/events`, … —
76
+ never `getDb()` directly. New SQLite reads/writes go in the
70
77
  matching `services/<thing>/` folder.
71
78
 
72
79
  - **User-scoped writes check `demandSigned()`** at the top of the
@@ -150,7 +157,7 @@ Client → /api/auth/google/app
150
157
  ```
151
158
 
152
159
  `signOut()` clears auth + onboarding state so the next user starts
153
- from Welcome (matches tranqs's pattern; onboarding is per-account).
160
+ from Welcome (onboarding is per-account).
154
161
 
155
162
  ## Premium
156
163
 
@@ -48,6 +48,9 @@
48
48
  {
49
49
  "android": {
50
50
  "minSdkVersion": 26
51
+ },
52
+ "ios": {
53
+ "deploymentTarget": "17.0"
51
54
  }
52
55
  }
53
56
  ],
@@ -20,7 +20,53 @@ if (typeof globalThis.document === 'undefined') {
20
20
  }
21
21
 
22
22
  import { registerRootComponent } from 'expo';
23
- import App from './App';
23
+ import { createElement, ComponentType } from 'react';
24
+ import { ScrollView, Text } from 'react-native';
25
+ import * as SplashScreen from 'expo-splash-screen';
26
+
27
+ // Boot-crash catcher: if ANY module in the app's import graph
28
+ // throws at load time, render the error on screen instead of
29
+ // hanging forever on the native splash. In Release there is no
30
+ // red box — without this, a load-time crash is invisible.
31
+ let App: ComponentType;
32
+ try {
33
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
34
+ App = (require('./App') as { default: ComponentType }).default;
35
+ } catch (err) {
36
+ const message = err instanceof Error
37
+ ? `${err.message}\n\n${err.stack ?? ''}`
38
+ : String(err);
39
+ App = function BootError() {
40
+ SplashScreen.hideAsync().catch(() => { /* best-effort */ });
41
+ return createElement(
42
+ ScrollView,
43
+ {
44
+ style: { flex: 1, backgroundColor: '#0F1416' },
45
+ contentContainerStyle: {
46
+ padding: 24,
47
+ paddingTop: 100
48
+ }
49
+ },
50
+ createElement(
51
+ Text,
52
+ {
53
+ style: {
54
+ color: '#f87171',
55
+ fontSize: 16,
56
+ fontWeight: '700',
57
+ marginBottom: 12
58
+ }
59
+ },
60
+ 'Erro ao iniciar o aplicativo'
61
+ ),
62
+ createElement(
63
+ Text,
64
+ { style: { color: '#e4e8f2', fontSize: 12 } },
65
+ message
66
+ )
67
+ );
68
+ };
69
+ }
24
70
 
25
71
  // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
26
72
  // It also ensures that whether you load the app in Expo Go or in a native build,
@@ -18,37 +18,41 @@
18
18
  "@react-navigation/bottom-tabs": "^7.7.3",
19
19
  "@react-navigation/native": "^7.1.19",
20
20
  "@react-navigation/native-stack": "^7.6.2",
21
- "expo": "~54.0.33",
22
- "expo-apple-authentication": "~8.0.8",
21
+ "expo": "~57.0.2",
22
+ "expo-apple-authentication": "~57.0.0",
23
23
  "expo-auth-session": "~7.0.10",
24
- "expo-build-properties": "~1.0.10",
25
- "expo-constants": "~18.0.13",
26
- "expo-file-system": "~18.0.4",
27
- "expo-localization": "~17.0.7",
28
- "expo-splash-screen": "^55.0.18",
29
- "expo-sqlite": "~16.0.10",
30
- "expo-status-bar": "~3.0.9",
31
- "expo-system-ui": "~6.0.7",
32
- "expo-web-browser": "~15.0.10",
24
+ "expo-build-properties": "~57.0.3",
25
+ "expo-constants": "~57.0.3",
26
+ "expo-file-system": "~57.0.0",
27
+ "expo-linking": "~57.0.1",
28
+ "expo-localization": "~57.0.0",
29
+ "expo-share-intent": "^8.0.0",
30
+ "expo-splash-screen": "~57.0.2",
31
+ "expo-sqlite": "~57.0.0",
32
+ "expo-status-bar": "~57.0.0",
33
+ "expo-system-ui": "~57.0.0",
34
+ "expo-web-browser": "~57.0.0",
33
35
  "i18next": "^23.16.5",
34
36
  "lucide-react-native": "^1.8.0",
35
37
  "react": "19.1.0",
36
- "react-native": "0.81.5",
38
+ "react-native": "0.83.10",
37
39
  "react-i18next": "^15.1.1",
40
+ "react-native-gesture-handler": "~3.0.2",
38
41
  "react-native-health": "^1.19.0",
39
42
  "react-native-health-connect": "^3.5.0",
40
43
  "react-native-iap": "^15.2.0",
41
44
  "react-native-nitro-modules": "^0.35.4",
42
- "react-native-reanimated": "~4.1.1",
43
- "react-native-safe-area-context": "^5.6.2",
44
- "react-native-screens": "~4.16.0",
45
+ "react-native-reanimated": "~4.5.1",
46
+ "react-native-safe-area-context": "^5.8.0",
47
+ "react-native-screens": "~4.25.2",
45
48
  "react-native-svg": "15.12.1",
46
- "react-native-toast-message": "^2.3.3"
49
+ "react-native-toast-message": "^2.3.3",
50
+ "react-native-worklets": "~0.10.1"
47
51
  },
48
52
  "devDependencies": {
49
53
  "@stylistic/eslint-plugin": "^2.13.0",
50
54
  "@types/react": "~19.1.0",
51
- "babel-preset-expo": "~54.0.0",
55
+ "babel-preset-expo": "~57.0.1",
52
56
  "eslint": "^9.20.1",
53
57
  "typescript": "~5.7.0",
54
58
  "typescript-eslint": "^8.24.0"
@@ -48,6 +48,13 @@ export default function AnimatedSplash({ onDone }: Props) {
48
48
  const exitSc = useRef(new Animated.Value(1)).current;
49
49
 
50
50
  useEffect(() => {
51
+ // Safety timeout: if the animation callback never fires
52
+ // (which can happen in Release builds with Hermes/new arch),
53
+ // force `onDone` after 4s so the app never hangs forever.
54
+ const fallback = setTimeout(() => {
55
+ onDone?.();
56
+ }, 4000);
57
+
51
58
  Animated.sequence([
52
59
  // Phase 1: entrance
53
60
  Animated.parallel([
@@ -126,10 +133,13 @@ export default function AnimatedSplash({ onDone }: Props) {
126
133
  })
127
134
  ])
128
135
  ]).start(() => {
136
+ clearTimeout(fallback);
129
137
  if (onDone) {
130
138
  onDone();
131
139
  }
132
140
  });
141
+
142
+ return () => clearTimeout(fallback);
133
143
  }, []);
134
144
 
135
145
  return (
@@ -287,9 +287,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
287
287
  console.warn('[auth] clearUserData error:', err);
288
288
  }
289
289
  // Clear onboarding state too: signing out should send the next
290
- // person back through Welcome → Onboarding → FirstAction. Same
291
- // pattern tranqs uses — onboarding is per-account, not
292
- // per-device.
290
+ // person back through Welcome → Onboarding → FirstAction.
291
+ // Onboarding is per-account, not per-device.
293
292
  await AsyncStorage.multiRemove([
294
293
  KEYS.token,
295
294
  KEYS.refresh,
@@ -70,8 +70,8 @@ Path alias: `@/*` → `./src/*`. Use it everywhere.
70
70
 
71
71
  Admin UI ships pt-BR strings inline today (the admin is for the
72
72
  operator, not the customer). If you need EN admin support later,
73
- mirror the mobile app's pattern: `src/i18n/{index,locales/*}` with
74
- `react-i18next` + `i18next`. The mobile app is the reference.
73
+ mirror the pattern: `src/i18n/{index,locales/*}` with
74
+ `react-i18next` + `i18next`.
75
75
 
76
76
  ## Charts
77
77
 
@@ -14,7 +14,7 @@ class FetchApi {
14
14
  async doRequest(
15
15
  url: string,
16
16
  body: unknown,
17
- method: 'GET' | 'POST' | 'DELETE',
17
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
18
18
  headers: Record<string, string> = {}
19
19
  ): Promise<any> {
20
20
  const isFormData = body instanceof FormData;
@@ -25,17 +25,29 @@ class FetchApi {
25
25
  }
26
26
 
27
27
  let parsedBody = body;
28
-
29
28
  if (parsedBody && typeof parsedBody === 'string') {
30
- parsedBody = JSON.parse(parsedBody);
29
+ try {
30
+ parsedBody = JSON.parse(parsedBody);
31
+ } catch {
32
+ // Keep original string if parse fails
33
+ }
31
34
  }
32
35
 
33
36
  let requestUrl = `${API_BASE_URL}/api/${url}`;
34
37
  let requestBody: string | FormData | null = null;
35
38
 
36
- if (method === 'GET' && parsedBody) {
37
- requestUrl = `${requestUrl}?${new URLSearchParams(parsedBody as Record<string, string>)}`;
38
- } else if (parsedBody) {
39
+ if (method === 'GET' && parsedBody && typeof parsedBody === 'object') {
40
+ const params = new URLSearchParams();
41
+ for (const [key, val] of Object.entries(parsedBody as Record<string, unknown>)) {
42
+ if (val !== undefined && val !== null) {
43
+ params.append(key, String(val));
44
+ }
45
+ }
46
+ const qs = params.toString();
47
+ if (qs) {
48
+ requestUrl = `${requestUrl}?${qs}`;
49
+ }
50
+ } else if (method !== 'GET' && parsedBody) {
39
51
  requestBody = isFormData
40
52
  ? (parsedBody as FormData)
41
53
  : JSON.stringify(parsedBody);
@@ -92,7 +104,7 @@ class FetchApi {
92
104
  export default function fetchApi(
93
105
  url: string,
94
106
  body?: unknown,
95
- method: 'GET' | 'POST' | 'DELETE' = 'GET',
107
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
96
108
  headers?: Record<string, string>,
97
109
  abortController?: AbortController
98
110
  ): Promise<any> {