umpordez 0.0.2 → 1.0.0
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/.claude/settings.local.json +16 -0
- package/build.mjs +409 -0
- package/cli.mjs +87 -109
- package/create.mjs +334 -0
- package/package.json +2 -7
- package/template/.claude/settings.local.json +20 -0
- package/template/.editorconfig +11 -0
- package/template/.github/copilot-instructions.md +186 -0
- package/template/.nvimrc +7 -0
- package/template/.vscode/extensions.json +9 -0
- package/template/.vscode/settings.json +28 -0
- package/template/AGENTS.md +305 -0
- package/template/CLAUDE.md +140 -0
- package/template/architecture.md +518 -0
- package/template/code.md +211 -0
- package/template/dev.sh +45 -0
- package/template/install.sh +47 -0
- package/template/misc/nginx.conf +151 -0
- package/template/misc/systemd/{{PROJECT_SLUG}}-api.service +31 -0
- package/template/misc/systemd/{{PROJECT_SLUG}}-site-api.service +31 -0
- package/template/misc/systemd/{{PROJECT_SLUG}}-site.service +30 -0
- package/template/seed.sh +23 -0
- package/template/server/.env.sample +40 -0
- package/template/server/.prettierrc +4 -0
- package/template/server/apps/api/app.ts +42 -0
- package/template/server/apps/api/routes/account.ts +98 -0
- package/template/server/apps/api/routes/admin.ts +80 -0
- package/template/server/apps/api/routes/auth.ts +222 -0
- package/template/server/apps/api/routes/files.ts +89 -0
- package/template/server/apps/api/routes/index.ts +19 -0
- package/template/server/apps/api/routes/user.ts +17 -0
- package/template/server/apps/shared/middlewares/demand-account-access.ts +52 -0
- package/template/server/apps/shared/middlewares/demand-admin-user.ts +25 -0
- package/template/server/apps/shared/middlewares/demand-user.ts +17 -0
- package/template/server/apps/shared/middlewares/error-handler.ts +28 -0
- package/template/server/apps/shared/middlewares/request-logger.ts +40 -0
- package/template/server/apps/shared/middlewares/try-set-user-by-token.ts +26 -0
- package/template/server/apps/shared/utils.ts +71 -0
- package/template/server/apps/site-api/app.ts +42 -0
- package/template/server/apps/site-api/routes/index.ts +10 -0
- package/template/server/apps/site-api/routes/public.ts +25 -0
- package/template/server/console/index.ts +26 -0
- package/template/server/console/runner.ts +67 -0
- package/template/server/console/tasks/example.ts +29 -0
- package/template/server/console/tasks/seed-admin.ts +71 -0
- package/template/server/core/context.ts +49 -0
- package/template/server/core/db.ts +12 -0
- package/template/server/core/email.ts +87 -0
- package/template/server/core/errors.ts +44 -0
- package/template/server/core/knexfile.ts +28 -0
- package/template/server/core/logger.ts +60 -0
- package/template/server/core/models/account.ts +319 -0
- package/template/server/core/models/auth.ts +317 -0
- package/template/server/core/models/base.ts +19 -0
- package/template/server/core/models/user.ts +343 -0
- package/template/server/core/s3.ts +183 -0
- package/template/server/emails/_styles.css +5 -0
- package/template/server/emails/_template.html +28 -0
- package/template/server/emails/accountWelcome.html +4 -0
- package/template/server/emails/forgetPassword.html +5 -0
- package/template/server/eslint.config.js +16 -0
- package/template/server/knex.sh +4 -0
- package/template/server/migrations/20260208000000_initial-schema.ts +56 -0
- package/template/server/migrations/20260208000001_seed-admin.ts +42 -0
- package/template/server/migrations/20260208000002_add-user-avatar.ts +13 -0
- package/template/server/package.json +58 -0
- package/template/server/tsconfig.json +27 -0
- package/template/server/types/api.ts +20 -0
- package/template/server/types/express.d.ts +35 -0
- package/template/ui/admin/.prettierrc +4 -0
- package/template/ui/admin/components.json +20 -0
- package/template/ui/admin/eslint.config.js +16 -0
- package/template/ui/admin/index.html +12 -0
- package/template/ui/admin/package.json +57 -0
- package/template/ui/admin/postcss.config.js +6 -0
- package/template/ui/admin/src/app.tsx +13 -0
- package/template/ui/admin/src/components/nav-user.tsx +95 -0
- package/template/ui/admin/src/components/theme-provider.tsx +65 -0
- package/template/ui/admin/src/components/theme-toggle.tsx +17 -0
- package/template/ui/admin/src/components/ui/badge.tsx +36 -0
- package/template/ui/admin/src/components/ui/button.tsx +56 -0
- package/template/ui/admin/src/components/ui/calendar.tsx +68 -0
- package/template/ui/admin/src/components/ui/card.tsx +79 -0
- package/template/ui/admin/src/components/ui/checkbox.tsx +28 -0
- package/template/ui/admin/src/components/ui/date-picker.tsx +78 -0
- package/template/ui/admin/src/components/ui/date-range-picker.tsx +208 -0
- package/template/ui/admin/src/components/ui/dialog.tsx +121 -0
- package/template/ui/admin/src/components/ui/dropdown-menu.tsx +200 -0
- package/template/ui/admin/src/components/ui/input.tsx +22 -0
- package/template/ui/admin/src/components/ui/label.tsx +24 -0
- package/template/ui/admin/src/components/ui/popover.tsx +29 -0
- package/template/ui/admin/src/components/ui/select.tsx +158 -0
- package/template/ui/admin/src/components/ui/separator.tsx +29 -0
- package/template/ui/admin/src/components/ui/sheet.tsx +136 -0
- package/template/ui/admin/src/components/ui/sidebar.tsx +759 -0
- package/template/ui/admin/src/components/ui/skeleton.tsx +15 -0
- package/template/ui/admin/src/components/ui/sonner.tsx +26 -0
- package/template/ui/admin/src/components/ui/switch.tsx +27 -0
- package/template/ui/admin/src/components/ui/table.tsx +117 -0
- package/template/ui/admin/src/components/ui/tabs.tsx +53 -0
- package/template/ui/admin/src/components/ui/textarea.tsx +22 -0
- package/template/ui/admin/src/components/ui/tooltip.tsx +28 -0
- package/template/ui/admin/src/hooks/queries/use-accounts.ts +68 -0
- package/template/ui/admin/src/hooks/queries/use-auth.ts +61 -0
- package/template/ui/admin/src/hooks/queries/use-dashboard.ts +70 -0
- package/template/ui/admin/src/hooks/queries/use-members.ts +90 -0
- package/template/ui/admin/src/hooks/queries/use-users.ts +99 -0
- package/template/ui/admin/src/hooks/use-confirm.tsx +92 -0
- package/template/ui/admin/src/hooks/use-mobile.tsx +22 -0
- package/template/ui/admin/src/hooks/use-sidebar.tsx +4 -0
- package/template/ui/admin/src/hooks/use-user.tsx +86 -0
- package/template/ui/admin/src/index.css +22 -0
- package/template/ui/admin/src/layouts/account.tsx +278 -0
- package/template/ui/admin/src/layouts/admin.tsx +182 -0
- package/template/ui/admin/src/layouts/public.tsx +9 -0
- package/template/ui/admin/src/layouts/user.tsx +107 -0
- package/template/ui/admin/src/lib/config.ts +9 -0
- package/template/ui/admin/src/lib/fetch-api.ts +101 -0
- package/template/ui/admin/src/lib/query-client.ts +12 -0
- package/template/ui/admin/src/lib/query-keys.ts +44 -0
- package/template/ui/admin/src/lib/utils.ts +24 -0
- package/template/ui/admin/src/main.tsx +17 -0
- package/template/ui/admin/src/pages/account/dashboard.tsx +128 -0
- package/template/ui/admin/src/pages/account/members.tsx +414 -0
- package/template/ui/admin/src/pages/account/settings.tsx +210 -0
- package/template/ui/admin/src/pages/admin/accounts.tsx +164 -0
- package/template/ui/admin/src/pages/admin/dashboard.tsx +243 -0
- package/template/ui/admin/src/pages/admin/users.tsx +395 -0
- package/template/ui/admin/src/pages/public/error.tsx +24 -0
- package/template/ui/admin/src/pages/public/forgot-password.tsx +74 -0
- package/template/ui/admin/src/pages/public/login.tsx +102 -0
- package/template/ui/admin/src/pages/public/reset-password.tsx +106 -0
- package/template/ui/admin/src/pages/public/signup.tsx +118 -0
- package/template/ui/admin/src/pages/user/profile.tsx +313 -0
- package/template/ui/admin/src/pages/user/select-account.tsx +114 -0
- package/template/ui/admin/src/router.tsx +71 -0
- package/template/ui/admin/tailwind.config.ts +71 -0
- package/template/ui/admin/tsconfig.json +24 -0
- package/template/ui/admin/vite.config.ts +15 -0
- package/template/ui/site/.env.sample +3 -0
- package/template/ui/site/.prettierrc +4 -0
- package/template/ui/site/app.ts +50 -0
- package/template/ui/site/eslint.config.js +16 -0
- package/template/ui/site/package.json +33 -0
- package/template/ui/site/styles/input.css +3 -0
- package/template/ui/site/tailwind.config.js +8 -0
- package/template/ui/site/tsconfig.json +15 -0
- package/template/ui/site/views/pages/home.ejs +305 -0
- package/template/ui/site/views/partials/footer.ejs +44 -0
- package/template/ui/site/views/partials/header.ejs +83 -0
- package/template-variables.mjs +186 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
This document describes the architecture patterns and how-tos for
|
|
4
|
+
the project. Read `code.md` for coding standards and style rules.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Context-Based Dependency Injection
|
|
9
|
+
|
|
10
|
+
Every request gets a fresh `Context` (created in middleware). The
|
|
11
|
+
Context holds all model instances. **ALL data access goes through
|
|
12
|
+
models — never raw queries in routes.**
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
// How it works:
|
|
16
|
+
req.context = new Context();
|
|
17
|
+
|
|
18
|
+
// In a route handler:
|
|
19
|
+
const users = await req.context.user.list({ page: 1 });
|
|
20
|
+
const account = await req.context.account.findById(id);
|
|
21
|
+
|
|
22
|
+
// Models access each other via context:
|
|
23
|
+
class SomeModel extends BaseModel {
|
|
24
|
+
async doSomething() {
|
|
25
|
+
const user = await this.context.user.findById(userId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Adding a new model:**
|
|
31
|
+
1. Create `server/core/models/my-entity.ts` extending `BaseModel`
|
|
32
|
+
2. Add to `server/core/context.ts`:
|
|
33
|
+
`myEntity: MyEntityModel;` +
|
|
34
|
+
`this.myEntity = new MyEntityModel(this);`
|
|
35
|
+
3. Access everywhere via `req.context.myEntity` or
|
|
36
|
+
`this.context.myEntity`
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Model Pattern
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import BaseModel from './base.js';
|
|
44
|
+
import { NotFoundError, ValidationError } from '#core/errors.js';
|
|
45
|
+
|
|
46
|
+
class MyEntityModel extends BaseModel {
|
|
47
|
+
// this.knex — Knex instance for queries
|
|
48
|
+
// this.context — access to other models
|
|
49
|
+
// this.db — Db singleton
|
|
50
|
+
|
|
51
|
+
async list(options: {
|
|
52
|
+
page?: number;
|
|
53
|
+
limit?: number;
|
|
54
|
+
search?: string;
|
|
55
|
+
} = {}) {
|
|
56
|
+
const page = Math.max(1, options.page || 1);
|
|
57
|
+
const limit = Math.min(100, Math.max(1, options.limit || 20));
|
|
58
|
+
const offset = (page - 1) * limit;
|
|
59
|
+
|
|
60
|
+
const baseQuery = this.knex('my_entities')
|
|
61
|
+
.where('account_id', accountId);
|
|
62
|
+
|
|
63
|
+
if (options.search?.trim()) {
|
|
64
|
+
baseQuery.whereILike(
|
|
65
|
+
'name',
|
|
66
|
+
`%${options.search.trim()}%`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const [{ count }] = await baseQuery.clone()
|
|
71
|
+
.count('id as count');
|
|
72
|
+
const total = parseInt(String(count), 10);
|
|
73
|
+
const rows = await baseQuery.clone()
|
|
74
|
+
.select('*')
|
|
75
|
+
.orderBy('utc_created_on', 'desc')
|
|
76
|
+
.limit(limit)
|
|
77
|
+
.offset(offset);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
rows,
|
|
81
|
+
total,
|
|
82
|
+
page,
|
|
83
|
+
limit,
|
|
84
|
+
totalPages: Math.ceil(total / limit),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async findById(id: string) {
|
|
89
|
+
const row = await this.knex('my_entities')
|
|
90
|
+
.where({ id })
|
|
91
|
+
.first();
|
|
92
|
+
|
|
93
|
+
if (!row) {
|
|
94
|
+
throw new NotFoundError('Entidade não encontrada');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return row;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Key rules:**
|
|
103
|
+
- Always filter by `account_id` in multi-tenant queries
|
|
104
|
+
- Use `PaginatedResult<T>` pattern:
|
|
105
|
+
`{ rows, total, page, limit, totalPages }`
|
|
106
|
+
- Use `whereILike` for case-insensitive search
|
|
107
|
+
- Parse JSON fields when reading, stringify when writing
|
|
108
|
+
- Throw `AppError` subclasses
|
|
109
|
+
(`ValidationError`, `NotFoundError`, etc.)
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Route Handler Pattern
|
|
114
|
+
|
|
115
|
+
Routes use `buildHandler()` which catches errors and handles Zod
|
|
116
|
+
validation automatically.
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import type { Express, Request, Response } from 'express';
|
|
120
|
+
import express from 'express';
|
|
121
|
+
import { z } from 'zod';
|
|
122
|
+
import trySetUserByTokenMiddleware
|
|
123
|
+
from '#shared/middlewares/try-set-user-by-token.js';
|
|
124
|
+
import demandUserMiddleware
|
|
125
|
+
from '#shared/middlewares/demand-user.js';
|
|
126
|
+
import demandAccountAccessMiddleware
|
|
127
|
+
from '#shared/middlewares/demand-account-access.js';
|
|
128
|
+
import { buildHandler } from '#shared/utils.js';
|
|
129
|
+
|
|
130
|
+
const router = express.Router();
|
|
131
|
+
|
|
132
|
+
const createSchema = z.object({
|
|
133
|
+
name: z.string().min(1, 'Nome é obrigatório'),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
async function handleCreate(
|
|
137
|
+
req: Request,
|
|
138
|
+
res: Response,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
const data = createSchema.parse(req.body);
|
|
141
|
+
const result = await req.context.myEntity.create(
|
|
142
|
+
req.accountId!,
|
|
143
|
+
data,
|
|
144
|
+
);
|
|
145
|
+
res.json({ ok: true, result });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
router.post('/my-entities', buildHandler(handleCreate));
|
|
149
|
+
|
|
150
|
+
// Export pattern — ALWAYS use makeEndpoint or makeRoutes
|
|
151
|
+
export default function makeEndpoint(app: Express): void {
|
|
152
|
+
app.use(
|
|
153
|
+
'/api/account/:accountId',
|
|
154
|
+
trySetUserByTokenMiddleware,
|
|
155
|
+
demandUserMiddleware,
|
|
156
|
+
demandAccountAccessMiddleware,
|
|
157
|
+
router
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Adding new routes:**
|
|
163
|
+
1. Create route file in `apps/api/routes/` following the pattern
|
|
164
|
+
2. Import and call from `apps/api/routes/index.ts`
|
|
165
|
+
3. Use appropriate middleware chain:
|
|
166
|
+
- Public routes: just `trySetUserByTokenMiddleware`
|
|
167
|
+
- User routes: `+ demandUserMiddleware`
|
|
168
|
+
- Admin routes: `+ demandAdminUserMiddleware`
|
|
169
|
+
- Account routes: `+ demandAccountAccessMiddleware`
|
|
170
|
+
(sets `req.accountId`, `req.accountRole`)
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Middleware Chain
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
requestLogger → contextInjection → trySetUserByToken
|
|
178
|
+
→ [demand*] → routes → errorHandler
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
| Middleware | Does |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `try-set-user-by-token` | Reads `auth_token` cookie, sets `req.user` if valid, always continues |
|
|
184
|
+
| `demand-user` | Requires `req.user`, returns 401 if missing |
|
|
185
|
+
| `demand-admin-user` | Requires `req.user.role === 'admin'`, returns 403 otherwise |
|
|
186
|
+
| `demand-account-access` | Checks user has access to `:accountId`, sets `req.accountId` + `req.accountRole`. Admins bypass check |
|
|
187
|
+
| `error-handler` | Final handler, catches all errors, returns JSON |
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Error Classes
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import {
|
|
195
|
+
ValidationError,
|
|
196
|
+
NotFoundError,
|
|
197
|
+
ConflictError,
|
|
198
|
+
ForbiddenError,
|
|
199
|
+
UnauthorizedError,
|
|
200
|
+
} from '#core/errors.js';
|
|
201
|
+
|
|
202
|
+
// 400
|
|
203
|
+
throw new ValidationError(
|
|
204
|
+
'A senha deve ter pelo menos 6 caracteres'
|
|
205
|
+
);
|
|
206
|
+
// 401
|
|
207
|
+
throw new UnauthorizedError('Não autorizado');
|
|
208
|
+
// 403
|
|
209
|
+
throw new ForbiddenError('Acesso negado');
|
|
210
|
+
// 404
|
|
211
|
+
throw new NotFoundError('Usuário não encontrado');
|
|
212
|
+
// 409
|
|
213
|
+
throw new ConflictError('Este email já está em uso');
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Transactions
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
await req.context.knexTransaction(async () => {
|
|
222
|
+
// All model operations here share the same transaction
|
|
223
|
+
const user = await req.context.user.create(data);
|
|
224
|
+
await req.context.account.addMember(accountId, { ... });
|
|
225
|
+
// If anything throws, everything rolls back
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Migrations
|
|
232
|
+
|
|
233
|
+
**Always use raw SQL:**
|
|
234
|
+
```typescript
|
|
235
|
+
export async function up(knex: Knex): Promise<void> {
|
|
236
|
+
await knex.schema.raw(`
|
|
237
|
+
CREATE TABLE my_entities (
|
|
238
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
239
|
+
account_id UUID NOT NULL REFERENCES accounts(id),
|
|
240
|
+
name VARCHAR(255) NOT NULL,
|
|
241
|
+
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
|
242
|
+
settings JSONB DEFAULT '{}',
|
|
243
|
+
utc_created_on TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
244
|
+
utc_updated_on TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
CREATE INDEX idx_my_entities_account
|
|
248
|
+
ON my_entities(account_id);
|
|
249
|
+
`);
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Conventions:** Always include `id` (UUID), `utc_created_on`,
|
|
254
|
+
`utc_updated_on`. Use PostgreSQL ENUMs for roles/statuses.
|
|
255
|
+
Filename: `YYYYMMDDHHMMSS_description.ts`.
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Authentication
|
|
260
|
+
|
|
261
|
+
- Backend sets `auth_token` httpOnly cookie on login/signup via
|
|
262
|
+
`setAuthCookie()`
|
|
263
|
+
- Frontend uses `credentials: 'include'` (handled by `fetchApi()`)
|
|
264
|
+
- Token read from `req.cookies?.auth_token` in
|
|
265
|
+
`try-set-user-by-token` middleware
|
|
266
|
+
- JWT contains `user_id`, 30-day expiration
|
|
267
|
+
- No Authorization header needed — cookies are automatic
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Multi-Tenancy
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
users (global identity)
|
|
275
|
+
↓ (many-to-many)
|
|
276
|
+
user_in_accounts (user_id + account_id + role)
|
|
277
|
+
↓
|
|
278
|
+
accounts (tenant)
|
|
279
|
+
↓
|
|
280
|
+
[domain entities scoped by account_id]
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**Roles:** `admin` (platform-wide), `owner` (account owner),
|
|
284
|
+
`manager` (account manager), `user` (regular user)
|
|
285
|
+
|
|
286
|
+
**Access control:** `demand-account-access` middleware verifies
|
|
287
|
+
access and sets `req.accountId` + `req.accountRole`. Admin users
|
|
288
|
+
bypass the `user_in_accounts` check.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## File Uploads (S3)
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
import { createS3Upload, generateSignedUrl } from '#core/s3.js';
|
|
296
|
+
|
|
297
|
+
const upload = createS3Upload({
|
|
298
|
+
folder: 'documents',
|
|
299
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
router.post(
|
|
303
|
+
'/upload',
|
|
304
|
+
upload.single('file'),
|
|
305
|
+
buildHandler(async (req, res) => {
|
|
306
|
+
const file = req.file as Express.MulterS3.File;
|
|
307
|
+
// Store file.key in the database — never file.location
|
|
308
|
+
res.json({ ok: true, key: file.key });
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Serve securely via signed URL (1-hour expiry)
|
|
313
|
+
const signedUrl = await generateSignedUrl(s3Key, 3600);
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Rules:**
|
|
317
|
+
- Store only the S3 **key** in the database, never the full URL
|
|
318
|
+
- Serve via **signed URLs** with short expiry
|
|
319
|
+
- Never expose raw S3 URLs to the frontend
|
|
320
|
+
- Delete old files from S3 when replacing
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Frontend: fetchApi
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
import fetchApi from '@/lib/fetch-api';
|
|
328
|
+
|
|
329
|
+
// GET (body becomes query params)
|
|
330
|
+
const data = await fetchApi('user/accounts');
|
|
331
|
+
const data = await fetchApi(
|
|
332
|
+
'admin/users',
|
|
333
|
+
{ page: 1, search: 'john' },
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// POST/DELETE
|
|
337
|
+
const data = await fetchApi(
|
|
338
|
+
'login',
|
|
339
|
+
{ email, password },
|
|
340
|
+
'POST',
|
|
341
|
+
);
|
|
342
|
+
const data = await fetchApi(
|
|
343
|
+
`admin/users/${id}`,
|
|
344
|
+
null,
|
|
345
|
+
'DELETE',
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// FormData (auto-detected, skips Content-Type)
|
|
349
|
+
const formData = new FormData();
|
|
350
|
+
formData.append('file', file);
|
|
351
|
+
const data = await fetchApi('files/upload', formData, 'POST');
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
- Uses `credentials: 'include'` for cookie auth
|
|
355
|
+
- On 401/403: clears storage, redirects to `/login`
|
|
356
|
+
- Always check `res.ok` before using data
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Frontend: React Query Hooks
|
|
361
|
+
|
|
362
|
+
All server state uses TanStack Query v5 with a structured query
|
|
363
|
+
key factory.
|
|
364
|
+
|
|
365
|
+
```typescript
|
|
366
|
+
// Query hook pattern
|
|
367
|
+
export function useMyEntities(
|
|
368
|
+
accountId: string,
|
|
369
|
+
filters: PaginationFilters,
|
|
370
|
+
) {
|
|
371
|
+
return useQuery({
|
|
372
|
+
queryKey: queryKeys.myEntities.list(accountId, filters),
|
|
373
|
+
placeholderData: keepPreviousData,
|
|
374
|
+
queryFn: async () => {
|
|
375
|
+
const res = await fetchApi(
|
|
376
|
+
`account/${accountId}/my-entities`,
|
|
377
|
+
filters,
|
|
378
|
+
);
|
|
379
|
+
if (!res.ok) {
|
|
380
|
+
throw new Error(res.message);
|
|
381
|
+
}
|
|
382
|
+
return res;
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Mutation hook pattern
|
|
388
|
+
export function useCreateMyEntity(accountId: string) {
|
|
389
|
+
const queryClient = useQueryClient();
|
|
390
|
+
return useMutation({
|
|
391
|
+
mutationFn: async (data) => {
|
|
392
|
+
const res = await fetchApi(
|
|
393
|
+
`account/${accountId}/my-entities`,
|
|
394
|
+
data,
|
|
395
|
+
'POST',
|
|
396
|
+
);
|
|
397
|
+
if (!res.ok) {
|
|
398
|
+
throw new Error(res.message);
|
|
399
|
+
}
|
|
400
|
+
return res;
|
|
401
|
+
},
|
|
402
|
+
onSuccess: () => {
|
|
403
|
+
queryClient.invalidateQueries({
|
|
404
|
+
queryKey: queryKeys.myEntities.all,
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**Key rules:**
|
|
412
|
+
- Use `keepPreviousData` on paginated/searchable queries
|
|
413
|
+
(prevents UI flash)
|
|
414
|
+
- Invalidate related queries in `onSuccess`
|
|
415
|
+
- Add query keys to `lib/query-keys.ts`
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## Frontend: Layouts and Routing
|
|
420
|
+
|
|
421
|
+
Routes are nested under layouts by role scope. Each layout
|
|
422
|
+
provides context:
|
|
423
|
+
|
|
424
|
+
| Layout | Auth | Provides | Route prefix |
|
|
425
|
+
|---|---|---|---|
|
|
426
|
+
| `PublicLayout` | None | — | `/` |
|
|
427
|
+
| `UserLayout` | `useUser()` | user, logout | `/user` |
|
|
428
|
+
| `AdminLayout` | admin role | sidebar, user | `/admin` |
|
|
429
|
+
| `AccountLayout` | account access | `useAccountContext()` | `/account/:accountId` |
|
|
430
|
+
|
|
431
|
+
**Adding a new account page:**
|
|
432
|
+
1. Create `src/pages/account/my-page.tsx`
|
|
433
|
+
2. Add route to `src/router.tsx`:
|
|
434
|
+
`{ path: 'my-entities', element: <MyPage /> }`
|
|
435
|
+
3. Add nav item to `src/layouts/account.tsx` in `getNavSections()`
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
## Frontend: Page Pattern (pagination + search)
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
export default function MyPage() {
|
|
443
|
+
const { accountId } = useParams<{ accountId: string }>();
|
|
444
|
+
const [search, setSearch] = useState('');
|
|
445
|
+
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
446
|
+
const [page, setPage] = useState(1);
|
|
447
|
+
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
const timer = setTimeout(() => {
|
|
450
|
+
setDebouncedSearch(search);
|
|
451
|
+
setPage(1);
|
|
452
|
+
}, 300);
|
|
453
|
+
return () => clearTimeout(timer);
|
|
454
|
+
}, [search]);
|
|
455
|
+
|
|
456
|
+
const { data, isLoading } = useMyEntities(accountId!, {
|
|
457
|
+
page,
|
|
458
|
+
limit: 20,
|
|
459
|
+
search: debouncedSearch || undefined,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const rows = data?.rows || [];
|
|
463
|
+
const total = data?.total || 0;
|
|
464
|
+
const totalPages = data?.totalPages || 0;
|
|
465
|
+
|
|
466
|
+
// ... render table with search input and pagination
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## Frontend: Confirm Modal
|
|
473
|
+
|
|
474
|
+
Use `useConfirm()` instead of `window.confirm()`:
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
const confirm = useConfirm();
|
|
478
|
+
|
|
479
|
+
async function handleDelete(item) {
|
|
480
|
+
const ok = await confirm({
|
|
481
|
+
title: 'Excluir Item',
|
|
482
|
+
description: `Tem certeza que deseja excluir "${item.name}"?`,
|
|
483
|
+
confirmLabel: 'Excluir',
|
|
484
|
+
variant: 'destructive',
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
if (!ok) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
await deleteMutation.mutateAsync(item.id);
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Console Tasks
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
npm run console -- --task=seed-admin
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Create tasks in `server/console/tasks/`:
|
|
504
|
+
```typescript
|
|
505
|
+
import type { ConsoleTask } from '../runner.js';
|
|
506
|
+
|
|
507
|
+
const task: ConsoleTask = {
|
|
508
|
+
name: 'my-task',
|
|
509
|
+
description: 'Does something useful',
|
|
510
|
+
async run(context, args) {
|
|
511
|
+
// context has all models
|
|
512
|
+
const users = await context.user.list();
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
export default task;
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Register in `console/index.ts`.
|