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.
Files changed (151) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/build.mjs +409 -0
  3. package/cli.mjs +87 -109
  4. package/create.mjs +334 -0
  5. package/package.json +2 -7
  6. package/template/.claude/settings.local.json +20 -0
  7. package/template/.editorconfig +11 -0
  8. package/template/.github/copilot-instructions.md +186 -0
  9. package/template/.nvimrc +7 -0
  10. package/template/.vscode/extensions.json +9 -0
  11. package/template/.vscode/settings.json +28 -0
  12. package/template/AGENTS.md +305 -0
  13. package/template/CLAUDE.md +140 -0
  14. package/template/architecture.md +518 -0
  15. package/template/code.md +211 -0
  16. package/template/dev.sh +45 -0
  17. package/template/install.sh +47 -0
  18. package/template/misc/nginx.conf +151 -0
  19. package/template/misc/systemd/{{PROJECT_SLUG}}-api.service +31 -0
  20. package/template/misc/systemd/{{PROJECT_SLUG}}-site-api.service +31 -0
  21. package/template/misc/systemd/{{PROJECT_SLUG}}-site.service +30 -0
  22. package/template/seed.sh +23 -0
  23. package/template/server/.env.sample +40 -0
  24. package/template/server/.prettierrc +4 -0
  25. package/template/server/apps/api/app.ts +42 -0
  26. package/template/server/apps/api/routes/account.ts +98 -0
  27. package/template/server/apps/api/routes/admin.ts +80 -0
  28. package/template/server/apps/api/routes/auth.ts +222 -0
  29. package/template/server/apps/api/routes/files.ts +89 -0
  30. package/template/server/apps/api/routes/index.ts +19 -0
  31. package/template/server/apps/api/routes/user.ts +17 -0
  32. package/template/server/apps/shared/middlewares/demand-account-access.ts +52 -0
  33. package/template/server/apps/shared/middlewares/demand-admin-user.ts +25 -0
  34. package/template/server/apps/shared/middlewares/demand-user.ts +17 -0
  35. package/template/server/apps/shared/middlewares/error-handler.ts +28 -0
  36. package/template/server/apps/shared/middlewares/request-logger.ts +40 -0
  37. package/template/server/apps/shared/middlewares/try-set-user-by-token.ts +26 -0
  38. package/template/server/apps/shared/utils.ts +71 -0
  39. package/template/server/apps/site-api/app.ts +42 -0
  40. package/template/server/apps/site-api/routes/index.ts +10 -0
  41. package/template/server/apps/site-api/routes/public.ts +25 -0
  42. package/template/server/console/index.ts +26 -0
  43. package/template/server/console/runner.ts +67 -0
  44. package/template/server/console/tasks/example.ts +29 -0
  45. package/template/server/console/tasks/seed-admin.ts +71 -0
  46. package/template/server/core/context.ts +49 -0
  47. package/template/server/core/db.ts +12 -0
  48. package/template/server/core/email.ts +87 -0
  49. package/template/server/core/errors.ts +44 -0
  50. package/template/server/core/knexfile.ts +28 -0
  51. package/template/server/core/logger.ts +60 -0
  52. package/template/server/core/models/account.ts +319 -0
  53. package/template/server/core/models/auth.ts +317 -0
  54. package/template/server/core/models/base.ts +19 -0
  55. package/template/server/core/models/user.ts +343 -0
  56. package/template/server/core/s3.ts +183 -0
  57. package/template/server/emails/_styles.css +5 -0
  58. package/template/server/emails/_template.html +28 -0
  59. package/template/server/emails/accountWelcome.html +4 -0
  60. package/template/server/emails/forgetPassword.html +5 -0
  61. package/template/server/eslint.config.js +16 -0
  62. package/template/server/knex.sh +4 -0
  63. package/template/server/migrations/20260208000000_initial-schema.ts +56 -0
  64. package/template/server/migrations/20260208000001_seed-admin.ts +42 -0
  65. package/template/server/migrations/20260208000002_add-user-avatar.ts +13 -0
  66. package/template/server/package.json +58 -0
  67. package/template/server/tsconfig.json +27 -0
  68. package/template/server/types/api.ts +20 -0
  69. package/template/server/types/express.d.ts +35 -0
  70. package/template/ui/admin/.prettierrc +4 -0
  71. package/template/ui/admin/components.json +20 -0
  72. package/template/ui/admin/eslint.config.js +16 -0
  73. package/template/ui/admin/index.html +12 -0
  74. package/template/ui/admin/package.json +57 -0
  75. package/template/ui/admin/postcss.config.js +6 -0
  76. package/template/ui/admin/src/app.tsx +13 -0
  77. package/template/ui/admin/src/components/nav-user.tsx +95 -0
  78. package/template/ui/admin/src/components/theme-provider.tsx +65 -0
  79. package/template/ui/admin/src/components/theme-toggle.tsx +17 -0
  80. package/template/ui/admin/src/components/ui/badge.tsx +36 -0
  81. package/template/ui/admin/src/components/ui/button.tsx +56 -0
  82. package/template/ui/admin/src/components/ui/calendar.tsx +68 -0
  83. package/template/ui/admin/src/components/ui/card.tsx +79 -0
  84. package/template/ui/admin/src/components/ui/checkbox.tsx +28 -0
  85. package/template/ui/admin/src/components/ui/date-picker.tsx +78 -0
  86. package/template/ui/admin/src/components/ui/date-range-picker.tsx +208 -0
  87. package/template/ui/admin/src/components/ui/dialog.tsx +121 -0
  88. package/template/ui/admin/src/components/ui/dropdown-menu.tsx +200 -0
  89. package/template/ui/admin/src/components/ui/input.tsx +22 -0
  90. package/template/ui/admin/src/components/ui/label.tsx +24 -0
  91. package/template/ui/admin/src/components/ui/popover.tsx +29 -0
  92. package/template/ui/admin/src/components/ui/select.tsx +158 -0
  93. package/template/ui/admin/src/components/ui/separator.tsx +29 -0
  94. package/template/ui/admin/src/components/ui/sheet.tsx +136 -0
  95. package/template/ui/admin/src/components/ui/sidebar.tsx +759 -0
  96. package/template/ui/admin/src/components/ui/skeleton.tsx +15 -0
  97. package/template/ui/admin/src/components/ui/sonner.tsx +26 -0
  98. package/template/ui/admin/src/components/ui/switch.tsx +27 -0
  99. package/template/ui/admin/src/components/ui/table.tsx +117 -0
  100. package/template/ui/admin/src/components/ui/tabs.tsx +53 -0
  101. package/template/ui/admin/src/components/ui/textarea.tsx +22 -0
  102. package/template/ui/admin/src/components/ui/tooltip.tsx +28 -0
  103. package/template/ui/admin/src/hooks/queries/use-accounts.ts +68 -0
  104. package/template/ui/admin/src/hooks/queries/use-auth.ts +61 -0
  105. package/template/ui/admin/src/hooks/queries/use-dashboard.ts +70 -0
  106. package/template/ui/admin/src/hooks/queries/use-members.ts +90 -0
  107. package/template/ui/admin/src/hooks/queries/use-users.ts +99 -0
  108. package/template/ui/admin/src/hooks/use-confirm.tsx +92 -0
  109. package/template/ui/admin/src/hooks/use-mobile.tsx +22 -0
  110. package/template/ui/admin/src/hooks/use-sidebar.tsx +4 -0
  111. package/template/ui/admin/src/hooks/use-user.tsx +86 -0
  112. package/template/ui/admin/src/index.css +22 -0
  113. package/template/ui/admin/src/layouts/account.tsx +278 -0
  114. package/template/ui/admin/src/layouts/admin.tsx +182 -0
  115. package/template/ui/admin/src/layouts/public.tsx +9 -0
  116. package/template/ui/admin/src/layouts/user.tsx +107 -0
  117. package/template/ui/admin/src/lib/config.ts +9 -0
  118. package/template/ui/admin/src/lib/fetch-api.ts +101 -0
  119. package/template/ui/admin/src/lib/query-client.ts +12 -0
  120. package/template/ui/admin/src/lib/query-keys.ts +44 -0
  121. package/template/ui/admin/src/lib/utils.ts +24 -0
  122. package/template/ui/admin/src/main.tsx +17 -0
  123. package/template/ui/admin/src/pages/account/dashboard.tsx +128 -0
  124. package/template/ui/admin/src/pages/account/members.tsx +414 -0
  125. package/template/ui/admin/src/pages/account/settings.tsx +210 -0
  126. package/template/ui/admin/src/pages/admin/accounts.tsx +164 -0
  127. package/template/ui/admin/src/pages/admin/dashboard.tsx +243 -0
  128. package/template/ui/admin/src/pages/admin/users.tsx +395 -0
  129. package/template/ui/admin/src/pages/public/error.tsx +24 -0
  130. package/template/ui/admin/src/pages/public/forgot-password.tsx +74 -0
  131. package/template/ui/admin/src/pages/public/login.tsx +102 -0
  132. package/template/ui/admin/src/pages/public/reset-password.tsx +106 -0
  133. package/template/ui/admin/src/pages/public/signup.tsx +118 -0
  134. package/template/ui/admin/src/pages/user/profile.tsx +313 -0
  135. package/template/ui/admin/src/pages/user/select-account.tsx +114 -0
  136. package/template/ui/admin/src/router.tsx +71 -0
  137. package/template/ui/admin/tailwind.config.ts +71 -0
  138. package/template/ui/admin/tsconfig.json +24 -0
  139. package/template/ui/admin/vite.config.ts +15 -0
  140. package/template/ui/site/.env.sample +3 -0
  141. package/template/ui/site/.prettierrc +4 -0
  142. package/template/ui/site/app.ts +50 -0
  143. package/template/ui/site/eslint.config.js +16 -0
  144. package/template/ui/site/package.json +33 -0
  145. package/template/ui/site/styles/input.css +3 -0
  146. package/template/ui/site/tailwind.config.js +8 -0
  147. package/template/ui/site/tsconfig.json +15 -0
  148. package/template/ui/site/views/pages/home.ejs +305 -0
  149. package/template/ui/site/views/partials/footer.ejs +44 -0
  150. package/template/ui/site/views/partials/header.ejs +83 -0
  151. package/template-variables.mjs +186 -0
package/create.mjs ADDED
@@ -0,0 +1,334 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { buildReplacements } from './template-variables.mjs';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const TEMPLATE_DIR = path.join(__dirname, 'template');
11
+
12
+ // umpordez.com design tokens
13
+ const green = chalk.hex('#02e027');
14
+ const cyan = chalk.hex('#00fff9');
15
+ const red = chalk.hex('#ff3c00');
16
+ const dim = chalk.hex('#a0a0a0');
17
+
18
+ const BINARY_EXTENSIONS = new Set([
19
+ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg',
20
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
21
+ '.zip', '.tar', '.gz', '.br',
22
+ '.pdf', '.mp3', '.mp4', '.webm', '.ogg',
23
+ ]);
24
+
25
+ function slugify(text) {
26
+ return text
27
+ .toLowerCase()
28
+ .normalize('NFD')
29
+ .replace(/[\u0300-\u036f]/g, '')
30
+ .replace(/[^a-z0-9]+/g, '-')
31
+ .replace(/^-|-$/g, '');
32
+ }
33
+
34
+ function toDbName(slug) {
35
+ return slug.replace(/-/g, '_');
36
+ }
37
+
38
+ async function promptProjectDetails() {
39
+ console.log(green.bold(' Project Setup'));
40
+ console.log(chalk.hex('#2c2c2c')(' ' + '\u2500'.repeat(40)));
41
+ console.log('');
42
+
43
+ const { name } = await inquirer.prompt([{
44
+ type: 'input',
45
+ name: 'name',
46
+ message: 'Project name (e.g. "Minha Empresa"):',
47
+ validate: (v) => v.trim() ? true : 'Project name is required',
48
+ }]);
49
+
50
+ const suggestedSlug = slugify(name);
51
+ const suggestedDb = toDbName(suggestedSlug);
52
+
53
+ const answers = await inquirer.prompt([
54
+ {
55
+ type: 'input',
56
+ name: 'slug',
57
+ message: 'Project slug:',
58
+ default: suggestedSlug,
59
+ validate: (v) => /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(v) || v.length === 1 && /^[a-z0-9]$/.test(v)
60
+ ? true
61
+ : 'Slug must be lowercase alphanumeric with hyphens (no leading/trailing hyphens)',
62
+ },
63
+ {
64
+ type: 'input',
65
+ name: 'description',
66
+ message: 'Short description:',
67
+ default: '',
68
+ },
69
+ {
70
+ type: 'input',
71
+ name: 'domain',
72
+ message: 'Domain (e.g. "minhaempresa.com.br"):',
73
+ validate: (v) => v.trim() ? true : 'Domain is required',
74
+ },
75
+ {
76
+ type: 'input',
77
+ name: 'dbName',
78
+ message: 'Database name:',
79
+ default: suggestedDb,
80
+ validate: (v) => /^[a-z_][a-z0-9_]*$/.test(v)
81
+ ? true
82
+ : 'Database name must be lowercase with underscores',
83
+ },
84
+ {
85
+ type: 'input',
86
+ name: 'outputDir',
87
+ message: 'Output directory:',
88
+ default: `./${suggestedSlug}`,
89
+ },
90
+ {
91
+ type: 'input',
92
+ name: 'adminEmail',
93
+ message: 'Admin email:',
94
+ default: 'admin@localhost.com',
95
+ validate: (v) => v.includes('@') ? true : 'Must be a valid email',
96
+ },
97
+ {
98
+ type: 'password',
99
+ name: 'adminPassword',
100
+ message: 'Admin password:',
101
+ validate: (v) => v.length >= 6 ? true : 'Password must be at least 6 characters',
102
+ },
103
+ {
104
+ type: 'input',
105
+ name: 'apiPort',
106
+ message: 'Admin API port:',
107
+ default: '4000',
108
+ validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
109
+ ? true
110
+ : 'Must be a valid port number',
111
+ },
112
+ {
113
+ type: 'input',
114
+ name: 'siteApiPort',
115
+ message: 'Site API port:',
116
+ default: '4001',
117
+ validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
118
+ ? true
119
+ : 'Must be a valid port number',
120
+ },
121
+ {
122
+ type: 'input',
123
+ name: 'adminUiPort',
124
+ message: 'Admin panel port:',
125
+ default: '5173',
126
+ validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
127
+ ? true
128
+ : 'Must be a valid port number',
129
+ },
130
+ {
131
+ type: 'input',
132
+ name: 'sitePort',
133
+ message: 'Public site port:',
134
+ default: '3000',
135
+ validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
136
+ ? true
137
+ : 'Must be a valid port number',
138
+ },
139
+ {
140
+ type: 'input',
141
+ name: 'primaryColor',
142
+ message: 'Primary color (hex):',
143
+ default: '#2563eb',
144
+ validate: (v) => /^#?([0-9a-fA-F]{6})$/.test(v.trim())
145
+ ? true
146
+ : 'Must be a valid hex color (e.g. #2563eb)',
147
+ filter: (v) => v.trim().startsWith('#') ? v.trim() : `#${v.trim()}`,
148
+ },
149
+ {
150
+ type: 'list',
151
+ name: 'defaultTheme',
152
+ message: 'Default theme:',
153
+ choices: [
154
+ { name: 'Light', value: 'light' },
155
+ { name: 'Dark', value: 'dark' },
156
+ { name: 'System (follows OS preference)', value: 'system' },
157
+ ],
158
+ default: 'light',
159
+ },
160
+ ]);
161
+
162
+ return { name: name.trim(), ...answers };
163
+ }
164
+
165
+ function showSummary(details) {
166
+ console.log('');
167
+ console.log(green.bold(' Project Summary'));
168
+ console.log(chalk.hex('#2c2c2c')(' ' + '\u2500'.repeat(40)));
169
+ console.log('');
170
+ console.log(` ${chalk.bold('Name:')} ${cyan(details.name)}`);
171
+ console.log(` ${chalk.bold('Slug:')} ${details.slug}`);
172
+ console.log(` ${chalk.bold('Description:')} ${details.description || dim('(none)')}`);
173
+ console.log(` ${chalk.bold('Domain:')} ${cyan(details.domain)}`);
174
+ console.log(` ${chalk.bold('Database:')} ${details.dbName}`);
175
+ console.log(` ${chalk.bold('Output:')} ${details.outputDir}`);
176
+ console.log(` ${chalk.bold('Admin:')} ${details.adminEmail}`);
177
+ console.log(` ${chalk.bold('Ports:')} API ${cyan(details.apiPort)} | Site API ${cyan(details.siteApiPort)} | Admin ${cyan(details.adminUiPort)} | Site ${cyan(details.sitePort)}`);
178
+ console.log(` ${chalk.bold('Color:')} ${chalk.hex(details.primaryColor)('\u2588\u2588')} ${details.primaryColor}`);
179
+ console.log(` ${chalk.bold('Theme:')} ${details.defaultTheme}`);
180
+ console.log('');
181
+ console.log(dim(' What gets generated:'));
182
+ console.log(dim(` ${green('>')} Admin API (Express + TypeScript)`));
183
+ console.log(dim(` ${green('>')} Site API (Express + TypeScript)`));
184
+ console.log(dim(` ${green('>')} React admin panel (Vite + shadcn/ui)`));
185
+ console.log(dim(` ${green('>')} Public site (EJS + Tailwind)`));
186
+ console.log(dim(` ${green('>')} PostgreSQL migrations + seed`));
187
+ console.log(dim(` ${green('>')} S3 file uploads`));
188
+ console.log(dim(` ${green('>')} Console task runner`));
189
+ console.log(dim(` ${green('>')} Production build system`));
190
+ console.log('');
191
+ }
192
+
193
+ async function getAllFiles(dir) {
194
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
195
+ const files = [];
196
+
197
+ for (const entry of entries) {
198
+ const fullPath = path.join(dir, entry.name);
199
+ if (entry.isDirectory()) {
200
+ files.push(...await getAllFiles(fullPath));
201
+ } else {
202
+ files.push(fullPath);
203
+ }
204
+ }
205
+
206
+ return files;
207
+ }
208
+
209
+ function isBinaryFile(filePath) {
210
+ return BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase());
211
+ }
212
+
213
+ function replacePlaceholders(content, replacements) {
214
+ let result = content;
215
+ for (const [placeholder, value] of Object.entries(replacements)) {
216
+ result = result.replaceAll(placeholder, value);
217
+ }
218
+ return result;
219
+ }
220
+
221
+ export async function createProject() {
222
+ const details = await promptProjectDetails();
223
+
224
+ showSummary(details);
225
+
226
+ const { confirm } = await inquirer.prompt([{
227
+ type: 'confirm',
228
+ name: 'confirm',
229
+ message: 'Look good? Let\'s go!',
230
+ default: true,
231
+ }]);
232
+
233
+ if (!confirm) {
234
+ console.log(chalk.yellow('Aborted. No files were created.'));
235
+ return;
236
+ }
237
+
238
+ const outputDir = path.resolve(details.outputDir);
239
+
240
+ if (fs.existsSync(outputDir)) {
241
+ const { overwrite } = await inquirer.prompt([{
242
+ type: 'confirm',
243
+ name: 'overwrite',
244
+ message: `Directory ${outputDir} already exists. Continue anyway?`,
245
+ default: false,
246
+ }]);
247
+
248
+ if (!overwrite) {
249
+ console.log(chalk.yellow('Aborted.'));
250
+ return;
251
+ }
252
+ }
253
+
254
+ const replacements = buildReplacements(details);
255
+
256
+ console.log('');
257
+
258
+ // [1/5] Copy template
259
+ console.log(green('[1/5]') + ' Copying template files...');
260
+ await fs.promises.cp(TEMPLATE_DIR, outputDir, { recursive: true });
261
+
262
+ // [2/5] Replace placeholders
263
+ const files = await getAllFiles(outputDir);
264
+ let replacedCount = 0;
265
+
266
+ for (const filePath of files) {
267
+ if (isBinaryFile(filePath)) {
268
+ continue;
269
+ }
270
+
271
+ const content = await fs.promises.readFile(filePath, 'utf-8');
272
+ const replaced = replacePlaceholders(content, replacements);
273
+
274
+ if (replaced !== content) {
275
+ await fs.promises.writeFile(filePath, replaced, 'utf-8');
276
+ replacedCount++;
277
+ }
278
+ }
279
+
280
+ console.log(green('[2/5]') + ` Replacing placeholders... ${dim(`(${replacedCount} files updated)`)}`);
281
+
282
+ // [3/5] Rename files
283
+ const allEntries = await getAllFiles(outputDir);
284
+ for (const filePath of allEntries) {
285
+ const dir = path.dirname(filePath);
286
+ const basename = path.basename(filePath);
287
+
288
+ if (basename.includes('{{PROJECT_SLUG}}')) {
289
+ const newName = basename.replaceAll('{{PROJECT_SLUG}}', details.slug);
290
+ const newPath = path.join(dir, newName);
291
+ await fs.promises.rename(filePath, newPath);
292
+ }
293
+ }
294
+
295
+ console.log(green('[3/5]') + ' Renaming template files...');
296
+
297
+ // [4/5] Make scripts executable
298
+ const scripts = [
299
+ 'dev.sh',
300
+ 'install.sh',
301
+ 'seed.sh',
302
+ 'server/knex.sh',
303
+ ];
304
+
305
+ for (const script of scripts) {
306
+ const scriptPath = path.join(outputDir, script);
307
+ if (fs.existsSync(scriptPath)) {
308
+ await fs.promises.chmod(scriptPath, 0o755);
309
+ }
310
+ }
311
+
312
+ console.log(green('[4/5]') + ' Making scripts executable...');
313
+
314
+ // [5/5] Git init
315
+ execSync('git init', { cwd: outputDir, stdio: 'ignore' });
316
+ console.log(green('[5/5]') + ' Initializing git repository...');
317
+
318
+ // Done!
319
+ console.log('');
320
+ console.log(green.bold(` \u2713 "${details.name}" created successfully!`));
321
+ console.log('');
322
+ console.log(chalk.bold(' Next steps:'));
323
+ console.log(` ${green('1.')} cd ${details.outputDir}`);
324
+ console.log(` ${green('2.')} ./install.sh`);
325
+ console.log(` ${green('3.')} ./seed.sh`);
326
+ console.log(` ${green('4.')} ./dev.sh`);
327
+ console.log('');
328
+ console.log(dim(` Admin panel: ${cyan(`http://localhost:${details.adminUiPort}`)}`));
329
+ console.log(dim(` Admin API: ${cyan(`http://localhost:${details.apiPort}`)}`));
330
+ console.log(dim(` Site: ${cyan(`http://localhost:${details.sitePort}`)}`));
331
+ console.log('');
332
+ console.log(dim(' Happy hacking! - ') + cyan('youtube.com/ligeiro'));
333
+ console.log('');
334
+ }
package/package.json CHANGED
@@ -1,14 +1,9 @@
1
1
  {
2
2
  "name": "umpordez",
3
3
  "type": "module",
4
- "version": "0.0.2",
5
- "description": "umpordez cli, interact, create, build your own meme",
4
+ "version": "1.0.0",
5
+ "description": "SaaS starter kit generator - multi-tenant TypeScript + React + PostgreSQL",
6
6
  "main": "cli.mjs",
7
- "imports": {
8
- "#root/*": "./*",
9
- "#tests/*": "./tests/*",
10
- "#core/*": "./core/*"
11
- },
12
7
  "scripts": {
13
8
  "test": "test"
14
9
  },
@@ -0,0 +1,20 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(ls:*)",
5
+ "Bash(npm install:*)",
6
+ "Bash(npm run:*)",
7
+ "Bash(npm ls:*)",
8
+ "Bash(npx tsc:*)",
9
+ "Bash(npx knex:*)",
10
+ "Bash(npx tsx:*)",
11
+ "Bash(node -e:*)",
12
+ "Bash(psql:*)",
13
+ "Bash(chmod:*)",
14
+ "Bash(tree:*)",
15
+ "Bash(curl:*)",
16
+ "Bash(git add:*)",
17
+ "Bash(git commit:*)"
18
+ ]
19
+ }
20
+ }
@@ -0,0 +1,11 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ trim_trailing_whitespace = true
8
+
9
+ [*.{ts,tsx,js,css,scss}]
10
+ indent_style = space
11
+ indent_size = 4
@@ -0,0 +1,186 @@
1
+ # Copilot Instructions
2
+
3
+ ## Project: {{PROJECT_NAME}}
4
+
5
+ {{PROJECT_DESCRIPTION}}
6
+
7
+ Multi-tenant SaaS application built with the **umpordez** architecture pattern for the Brazilian market.
8
+
9
+ ## Tech Stack
10
+
11
+ - **Backend:** TypeScript, Express.js, PostgreSQL, Knex.js, Zod
12
+ - **Admin UI:** React 18, Vite, TailwindCSS, shadcn/ui (Radix), React Router DOM 6, TanStack Query v5, React Hook Form + Zod
13
+ - **Site UI:** Express + EJS + TailwindCSS
14
+ - **Auth:** httpOnly JWT cookies (30-day expiry)
15
+ - **Uploads:** AWS S3 via multer-s3 with signed URLs
16
+
17
+ ## Architecture
18
+
19
+ - Multi-API: `server/apps/api/` (admin, port 4000) + `server/apps/site-api/` (site, port 4001)
20
+ - Shared core: `server/core/` (db, models, context, s3, email, errors, logger)
21
+ - Shared middleware: `server/apps/shared/` (auth guards, error handler, buildHandler)
22
+ - Context-based DI: fresh `Context` per request with all models instantiated
23
+ - Models extend `BaseModel`, access DB via `this.knex`, other models via `this.context`
24
+ - Routes use `buildHandler()` wrapper for async error handling + Zod validation
25
+ - Multi-tenant: users -> user_in_accounts -> accounts -> domain entities
26
+ - Roles: admin (platform-wide), owner, manager, user (per-account)
27
+
28
+ ## Code Style
29
+
30
+ - 4-space indentation, semicolons required, LF line endings
31
+ - TypeScript strict mode everywhere
32
+ - Code in English, UI text in Portuguese (Brazilian market)
33
+ - Path aliases: `#core/*`, `#shared/*`, `#api/*` (server), `@/*` (admin UI)
34
+
35
+ ## Key Patterns — Follow These When Generating Code
36
+
37
+ ### Backend: Models (data access)
38
+
39
+ All database queries go in models, never in route handlers directly. Models extend `BaseModel` and live in `server/core/models/`.
40
+
41
+ ```typescript
42
+ import BaseModel from './base.js';
43
+ import { NotFoundError, ValidationError } from '#core/errors.js';
44
+
45
+ class MyEntityModel extends BaseModel {
46
+ async list(options = {}) {
47
+ const page = Math.max(1, options.page || 1);
48
+ const limit = Math.min(100, Math.max(1, options.limit || 20));
49
+ const offset = (page - 1) * limit;
50
+ const baseQuery = this.knex('my_entities').where('account_id', accountId);
51
+ if (options.search?.trim()) {
52
+ baseQuery.whereILike('name', `%${options.search.trim()}%`);
53
+ }
54
+ const [{ count }] = await baseQuery.clone().count('id as count');
55
+ const total = parseInt(String(count), 10);
56
+ const rows = await baseQuery.clone().select('*')
57
+ .orderBy('utc_created_on', 'desc').limit(limit).offset(offset);
58
+ return { rows, total, page, limit, totalPages: Math.ceil(total / limit) };
59
+ }
60
+
61
+ async findById(id: string) {
62
+ const row = await this.knex('my_entities').where({ id }).first();
63
+ if (!row) throw new NotFoundError('Entidade não encontrada');
64
+ return row;
65
+ }
66
+ }
67
+ ```
68
+
69
+ Register in `server/core/context.ts`: add field + `this.myEntity = new MyEntityModel(this);`
70
+
71
+ ### Backend: Routes
72
+
73
+ ```typescript
74
+ import { buildHandler } from '#shared/utils.js';
75
+ import { z } from 'zod';
76
+
77
+ const createSchema = z.object({ name: z.string().min(1, 'Nome é obrigatório') });
78
+
79
+ async function handleCreate(req: Request, res: Response): Promise<void> {
80
+ const data = createSchema.parse(req.body);
81
+ const result = await req.context.myEntity.create(req.accountId!, data);
82
+ res.json({ ok: true, result });
83
+ }
84
+
85
+ router.post('/my-entities', buildHandler(handleCreate));
86
+
87
+ export default function makeEndpoint(app: Express): void {
88
+ app.use('/api/account/:accountId',
89
+ trySetUserByTokenMiddleware, demandUserMiddleware,
90
+ demandAccountAccessMiddleware, router);
91
+ }
92
+ ```
93
+
94
+ ### Backend: Errors
95
+
96
+ ```typescript
97
+ throw new ValidationError('Campo obrigatório'); // 400
98
+ throw new NotFoundError('Não encontrado'); // 404
99
+ throw new ConflictError('Já existe'); // 409
100
+ throw new ForbiddenError('Sem permissão'); // 403
101
+ throw new UnauthorizedError('Não autorizado'); // 401
102
+ ```
103
+
104
+ ### Backend: Transactions
105
+
106
+ ```typescript
107
+ await req.context.knexTransaction(async () => {
108
+ // All model ops share the same transaction; rollback on throw
109
+ });
110
+ ```
111
+
112
+ ### Backend: Migrations (raw SQL)
113
+
114
+ ```typescript
115
+ export async function up(knex: Knex): Promise<void> {
116
+ await knex.schema.raw(`
117
+ CREATE TABLE my_entities (
118
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
119
+ account_id UUID NOT NULL REFERENCES accounts(id),
120
+ name VARCHAR(255) NOT NULL,
121
+ utc_created_on TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
122
+ utc_updated_on TIMESTAMP WITH TIME ZONE DEFAULT NOW()
123
+ );
124
+ CREATE INDEX idx_my_entities_account ON my_entities(account_id);
125
+ `);
126
+ }
127
+ ```
128
+
129
+ ### Frontend: fetchApi
130
+
131
+ ```typescript
132
+ import fetchApi from '@/lib/fetch-api';
133
+ const data = await fetchApi('user/accounts'); // GET
134
+ const data = await fetchApi('admin/users', { page: 1 }); // GET with params
135
+ const data = await fetchApi('login', { email, password }, 'POST'); // POST
136
+ ```
137
+
138
+ Uses `credentials: 'include'` for cookie auth.
139
+
140
+ ### Frontend: React Query hooks
141
+
142
+ ```typescript
143
+ export function useMyEntities(accountId: string, filters: PaginationFilters) {
144
+ return useQuery({
145
+ queryKey: queryKeys.myEntities.list(accountId, filters),
146
+ placeholderData: keepPreviousData,
147
+ queryFn: async () => {
148
+ const res = await fetchApi(`account/${accountId}/my-entities`, filters);
149
+ if (!res.ok) throw new Error(res.message);
150
+ return res;
151
+ },
152
+ });
153
+ }
154
+ ```
155
+
156
+ Use `keepPreviousData` on paginated/searchable queries. Add keys to `lib/query-keys.ts`. Invalidate related queries in mutation `onSuccess`.
157
+
158
+ ### Frontend: Confirm modal
159
+
160
+ ```typescript
161
+ const confirm = useConfirm();
162
+ const ok = await confirm({
163
+ title: 'Excluir', description: 'Tem certeza?',
164
+ confirmLabel: 'Excluir', variant: 'destructive',
165
+ });
166
+ ```
167
+
168
+ ### Frontend: Layouts
169
+
170
+ | Layout | Auth | Route prefix |
171
+ |---|---|---|
172
+ | `PublicLayout` | None | `/` |
173
+ | `UserLayout` | `useUser()` | `/user` |
174
+ | `AdminLayout` | admin role | `/admin` |
175
+ | `AccountLayout` | account access | `/account/:accountId` |
176
+
177
+ ## File Naming
178
+
179
+ - Components: PascalCase (`AppSidebar.tsx`)
180
+ - Hooks: kebab-case with `use-` prefix (`use-user.tsx`)
181
+ - Routes/Models/Utils: kebab-case (`demand-user.ts`, `fetch-api.ts`)
182
+ - Migrations: timestamp prefix (`20260208000000_description.ts`)
183
+
184
+ ## Multi-Tenancy
185
+
186
+ Always filter by `account_id` in tenant-scoped queries. The `demand-account-access` middleware verifies access and sets `req.accountId` + `req.accountRole`. Admin users bypass the check.
@@ -0,0 +1,7 @@
1
+ set tabstop=4
2
+ set shiftwidth=4
3
+ set expandtab
4
+ set smartindent
5
+ set fileformat=unix
6
+ set encoding=utf-8
7
+ set colorcolumn=80
@@ -0,0 +1,9 @@
1
+ {
2
+ "recommendations": [
3
+ "esbenp.prettier-vscode",
4
+ "dbaeumer.vscode-eslint",
5
+ "bradlc.vscode-tailwindcss",
6
+ "editorconfig.editorconfig",
7
+ "ms-vscode.vscode-typescript-next"
8
+ ]
9
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "editor.tabSize": 4,
3
+ "editor.insertSpaces": true,
4
+ "editor.formatOnSave": true,
5
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
6
+ "editor.codeActionsOnSave": {
7
+ "source.fixAll.eslint": "explicit"
8
+ },
9
+ "files.eol": "\n",
10
+ "files.trimTrailingWhitespace": true,
11
+ "files.insertFinalNewline": true,
12
+ "typescript.preferences.importModuleSpecifier": "non-relative",
13
+ "typescript.tsdk": "node_modules/typescript/lib",
14
+ "search.exclude": {
15
+ "**/node_modules": true,
16
+ "**/build": true,
17
+ "**/dist": true
18
+ },
19
+ "[typescript]": {
20
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
21
+ },
22
+ "[typescriptreact]": {
23
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
24
+ },
25
+ "[json]": {
26
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
27
+ }
28
+ }