create-bw-app 0.19.0 → 0.20.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/README.md +1 -1
- package/package.json +1 -1
- package/src/add.mjs +10 -2
- package/src/admin.mjs +8 -8
- package/src/app-manifest.mjs +10 -1
- package/src/constants.mjs +10 -9
- package/src/diff.mjs +3 -3
- package/src/generator.mjs +221 -1
- package/src/migrations.mjs +4 -1
- package/src/remove.mjs +18 -2
- package/src/safe-path.mjs +46 -0
- package/src/scaffold-cmd.mjs +3 -10
- package/src/scaffold.mjs +3 -2
- package/src/update.mjs +35 -0
- package/src/upgrade.mjs +5 -3
- package/template/base/app/(auth)/login/page.tsx +5 -1
- package/template/base/app/(shell)/shell-layout-client.tsx +29 -15
- package/template/base/app/layout.tsx +12 -4
- package/template/base/config/module-toolbar-controls.tsx +6 -0
- package/template/supabase/modules/admin/migrations/20260729150000_bootstrap_first_admin_remove_force.sql +116 -0
- package/template/supabase/modules/marketing/migrations/20260729170000_marketing_webhook_transaction.sql +106 -0
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ bw admin create --email owner@example.com
|
|
|
39
39
|
- `bw add <moduleKey>` resolves requirements, installs thin package mounts and module wiring, and appends migrations.
|
|
40
40
|
- `bw upgrade [moduleKey]` includes the existing managed update flow plus forward-only module migrations.
|
|
41
41
|
- `bw doctor` checks package, config, scaffold, environment-name, migration, configured function-region, and deployed function-region consistency. Pass `--deployment-url` to inspect the deployed `x-vercel-id`; add `--report` to stamp the result in the app manifest.
|
|
42
|
-
- `bw admin create --email <email>` creates a passwordless Supabase Auth user, transactionally ensures its profile and `admin` assignment, then sends the Core Auth `/reset-password` flow. It refuses an
|
|
42
|
+
- `bw admin create --email <email>` creates a passwordless Supabase Auth user, transactionally ensures its profile and `admin` assignment, then sends the Core Auth `/reset-password` flow. It always refuses when the project already has an admin (use the in-app admin role controls to add administrators) and never promotes an existing Auth user.
|
|
43
43
|
- All mutating commands support `--dry-run`.
|
|
44
44
|
|
|
45
45
|
## Update existing apps
|
package/package.json
CHANGED
package/src/add.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { stdout as output } from "node:process";
|
|
4
4
|
import { SELECTABLE_MODULES } from "./constants.mjs";
|
|
5
|
-
import { TEMPLATE_ROOT, createAppContextFile, createDbInstallPlan, createNextConfig, createPlatformGlobalsCss, createPlatformModulesConfigFile, createShellConfig, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
|
|
5
|
+
import { TEMPLATE_ROOT, createAppContextFile, createDbInstallPlan, createModuleToolbarControlsConfig, createNextConfig, createOptionalModuleRouteFiles, createPlatformGlobalsCss, createPlatformModulesConfigFile, createShellConfig, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
|
|
6
6
|
import { collectScaffoldFiles, findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, resolveModuleClosure, satisfiesVersion, writeAppManifest } from "./app-manifest.mjs";
|
|
7
7
|
import { applyMigrationWrites, planMigrationAppends } from "./migrations.mjs";
|
|
8
8
|
|
|
@@ -66,9 +66,11 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
|
|
|
66
66
|
const managedWrites = {
|
|
67
67
|
"next.config.ts": createNextConfig({ template: "platform", selectedModules: installedModuleKeys }),
|
|
68
68
|
"app/globals.css": await createPlatformGlobalsCss(installedModuleKeys),
|
|
69
|
+
"config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(installedModuleKeys),
|
|
69
70
|
"config/modules.ts": createPlatformModulesConfigFile(installedModuleKeys),
|
|
70
71
|
"config/shell.ts": createShellConfig(installedModuleKeys),
|
|
71
72
|
"docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: installedModuleKeys.filter((key) => key !== "orgs"), dbInstallPlan }),
|
|
73
|
+
...createOptionalModuleRouteFiles(installedModuleKeys),
|
|
72
74
|
};
|
|
73
75
|
|
|
74
76
|
const summary = [
|
|
@@ -93,7 +95,13 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
|
|
|
93
95
|
for (const key of newModules) appManifest.modules[key] = { version: catalog[key].version, installedAt: now, exposed: true };
|
|
94
96
|
appManifest.migrationCursor = migrationPlan.nextCursor;
|
|
95
97
|
const collectedScaffoldFiles = await collectScaffoldFiles(targetDir, installedModuleKeys);
|
|
96
|
-
|
|
98
|
+
const refreshedScaffoldFiles = Object.fromEntries(
|
|
99
|
+
Object.entries(collectedScaffoldFiles).map(([relativePath, record]) => {
|
|
100
|
+
const intent = appManifest.scaffoldFiles[relativePath]?.intent;
|
|
101
|
+
return [relativePath, intent ? { ...record, intent } : record];
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
appManifest.scaffoldFiles = { ...appManifest.scaffoldFiles, ...refreshedScaffoldFiles };
|
|
97
105
|
await writeAppManifest(targetDir, appManifest);
|
|
98
106
|
output.write(`Installed ${newModules.length} module${newModules.length === 1 ? "" : "s"}. ${migrationPlan.writes.length > 0 ? "Run your Supabase migration apply command. " : ""}Run your package manager install command next.\n`);
|
|
99
107
|
return { dryRun: false, newModules, migrationPlan };
|
package/src/admin.mjs
CHANGED
|
@@ -8,7 +8,6 @@ const HELP = `Usage: bw admin create --email <email> [options]
|
|
|
8
8
|
Options:
|
|
9
9
|
--email <email> Email address for the first administrator
|
|
10
10
|
--target-dir <path> App directory (defaults to cwd)
|
|
11
|
-
--force Allow creation when the project already has an admin
|
|
12
11
|
--dry-run Validate and inspect without creating the user
|
|
13
12
|
--help Show this help
|
|
14
13
|
|
|
@@ -94,7 +93,7 @@ async function deleteCreatedAuthUser(supabase, userId) {
|
|
|
94
93
|
function bootstrapErrorMessage(error) {
|
|
95
94
|
const message = error?.message || "Unknown database error";
|
|
96
95
|
if (message.toLowerCase().includes("administrator already exists")) {
|
|
97
|
-
return "A project administrator already exists.
|
|
96
|
+
return "A project administrator already exists. Use the in-app admin role controls to add administrators.";
|
|
98
97
|
}
|
|
99
98
|
return `Could not create the administrator profile and role: ${message}`;
|
|
100
99
|
}
|
|
@@ -110,6 +109,9 @@ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions
|
|
|
110
109
|
if ("password" in argvOptions) {
|
|
111
110
|
throw new Error("Passwords are not accepted by bw admin create. The command sends a password-set email instead.");
|
|
112
111
|
}
|
|
112
|
+
if ("force" in argvOptions) {
|
|
113
|
+
throw new Error("--force has been removed; use the in-app admin role controls to add administrators.");
|
|
114
|
+
}
|
|
113
115
|
|
|
114
116
|
const email = normalizeEmail(argvOptions.email);
|
|
115
117
|
if (!validateEmail(email)) {
|
|
@@ -129,9 +131,9 @@ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions
|
|
|
129
131
|
});
|
|
130
132
|
|
|
131
133
|
const hasAdmin = await projectHasAdmin(supabase);
|
|
132
|
-
if (hasAdmin
|
|
134
|
+
if (hasAdmin) {
|
|
133
135
|
throw new Error(
|
|
134
|
-
"A project administrator already exists. Refusing bootstrap;
|
|
136
|
+
"A project administrator already exists. Refusing bootstrap; use the in-app admin role controls to add administrators.",
|
|
135
137
|
);
|
|
136
138
|
}
|
|
137
139
|
|
|
@@ -144,9 +146,9 @@ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions
|
|
|
144
146
|
|
|
145
147
|
if (argvOptions.dryRun) {
|
|
146
148
|
(runtimeOptions.output || output).write(
|
|
147
|
-
`DRY RUN ${email} can be created
|
|
149
|
+
`DRY RUN ${email} can be created as the first administrator.\n`,
|
|
148
150
|
);
|
|
149
|
-
return { dryRun: true, email
|
|
151
|
+
return { dryRun: true, email };
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
const { data: created, error: createError } = await supabase.auth.admin.createUser({
|
|
@@ -165,7 +167,6 @@ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions
|
|
|
165
167
|
{
|
|
166
168
|
p_user_id: userId,
|
|
167
169
|
p_email: email,
|
|
168
|
-
p_force: Boolean(argvOptions.force),
|
|
169
170
|
},
|
|
170
171
|
);
|
|
171
172
|
if (bootstrapError) throw new Error(bootstrapErrorMessage(bootstrapError));
|
|
@@ -196,7 +197,6 @@ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions
|
|
|
196
197
|
email,
|
|
197
198
|
userId,
|
|
198
199
|
profileId,
|
|
199
|
-
forced: Boolean(argvOptions.force),
|
|
200
200
|
resetPasswordUrl,
|
|
201
201
|
};
|
|
202
202
|
}
|
package/src/app-manifest.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { APP_DEPENDENCY_DEFAULTS, MODULE_STARTER_FILES, PLATFORM_STARTER_FILES, SELECTABLE_MODULES } from "./constants.mjs";
|
|
6
|
+
import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
|
|
6
7
|
|
|
7
8
|
const TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "template");
|
|
8
9
|
|
|
@@ -86,6 +87,10 @@ export async function readAppManifest(targetDir, { required = true } = {}) {
|
|
|
86
87
|
if (!manifest && required) {
|
|
87
88
|
throw new Error(`No BrightWeb app manifest found at ${APP_MANIFEST_PATH}. Pre-manifest apps must be adopted before using bw.`);
|
|
88
89
|
}
|
|
90
|
+
if (manifest) {
|
|
91
|
+
const errors = validateAppManifest(manifest);
|
|
92
|
+
if (errors.length > 0) throw new Error(`Invalid BrightWeb app manifest: ${errors.join("; ")}`);
|
|
93
|
+
}
|
|
89
94
|
return manifest;
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -114,10 +119,14 @@ export function validateAppManifest(manifest) {
|
|
|
114
119
|
if (!manifest[key] || typeof manifest[key] !== "object" || Array.isArray(manifest[key])) errors.push(`${key} must be an object`);
|
|
115
120
|
}
|
|
116
121
|
if (!Array.isArray(manifest.managedFiles) || manifest.managedFiles.some((entry) => typeof entry !== "string")) errors.push("managedFiles must be an array of paths");
|
|
122
|
+
else for (const [index, relativePath] of manifest.managedFiles.entries()) {
|
|
123
|
+
try { normalizeSafeRelativePath(relativePath, `managedFiles[${index}]`); } catch (error) { errors.push(error.message); }
|
|
124
|
+
}
|
|
117
125
|
for (const [key, entry] of Object.entries(manifest.modules || {})) {
|
|
118
126
|
if (!entry || !cleanVersion(entry.version) || typeof entry.installedAt !== "string" || typeof entry.exposed !== "boolean") errors.push(`modules.${key} is invalid`);
|
|
119
127
|
}
|
|
120
128
|
for (const [relativePath, entry] of Object.entries(manifest.scaffoldFiles || {})) {
|
|
129
|
+
try { normalizeSafeRelativePath(relativePath, `scaffoldFiles path`); } catch (error) { errors.push(error.message); }
|
|
121
130
|
if (!entry || typeof entry.module !== "string" || !/^sha256:[a-f0-9]{64}$/.test(entry.hash || "") || !["current", "drifted", "missing"].includes(entry.status) || (entry.intent != null && !["managed", "owned", "skipped"].includes(entry.intent))) errors.push(`scaffoldFiles.${relativePath} is invalid`);
|
|
122
131
|
}
|
|
123
132
|
if (manifest.lastDoctor != null && (typeof manifest.lastDoctor.at !== "string" || typeof manifest.lastDoctor.ok !== "boolean")) errors.push("lastDoctor is invalid");
|
|
@@ -160,7 +169,7 @@ export async function collectScaffoldFiles(targetDir, selectedModules) {
|
|
|
160
169
|
}
|
|
161
170
|
const result = {};
|
|
162
171
|
for (const [relativePath, moduleKey] of Array.from(files.entries()).sort()) {
|
|
163
|
-
const targetPath =
|
|
172
|
+
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Scaffold file path");
|
|
164
173
|
if (await pathExists(targetPath)) result[relativePath] = { module: moduleKey, hash: await hashFile(targetPath), status: "current" };
|
|
165
174
|
}
|
|
166
175
|
return result;
|
package/src/constants.mjs
CHANGED
|
@@ -154,6 +154,7 @@ export const PLATFORM_STARTER_FILES = [
|
|
|
154
154
|
"app/(shell)/dashboard/dashboard-live-mount.tsx",
|
|
155
155
|
"app/(shell)/dashboard/page.tsx",
|
|
156
156
|
"app/api/account/route.ts",
|
|
157
|
+
"app/api/cron/keepalive/route.ts",
|
|
157
158
|
"app/api/invitations/_dependencies.ts",
|
|
158
159
|
"app/api/invitations/[invitationId]/route.ts",
|
|
159
160
|
"app/api/invitations/[invitationId]/accept/route.ts",
|
|
@@ -170,16 +171,16 @@ export const PLATFORM_STARTER_FILES = [
|
|
|
170
171
|
];
|
|
171
172
|
|
|
172
173
|
export const APP_DEPENDENCY_DEFAULTS = {
|
|
173
|
-
"@brightweblabs/app-shell": "^0.
|
|
174
|
-
"@brightweblabs/core-auth": "^0.7.
|
|
174
|
+
"@brightweblabs/app-shell": "^0.8.0",
|
|
175
|
+
"@brightweblabs/core-auth": "^0.7.3",
|
|
175
176
|
"@brightweblabs/infra": "^0.4.0",
|
|
176
|
-
"@brightweblabs/module-admin": "^0.5.
|
|
177
|
-
"@brightweblabs/module-crm": "^0.10.
|
|
178
|
-
"@brightweblabs/module-marketing": "^0.2.
|
|
179
|
-
"@brightweblabs/module-orgs": "^0.3.
|
|
180
|
-
"@brightweblabs/module-projects": "^0.9.
|
|
181
|
-
"@brightweblabs/theme": "^0.5.
|
|
182
|
-
"@brightweblabs/ui": "^1.
|
|
177
|
+
"@brightweblabs/module-admin": "^0.5.11",
|
|
178
|
+
"@brightweblabs/module-crm": "^0.10.1",
|
|
179
|
+
"@brightweblabs/module-marketing": "^0.2.10",
|
|
180
|
+
"@brightweblabs/module-orgs": "^0.3.11",
|
|
181
|
+
"@brightweblabs/module-projects": "^0.9.2",
|
|
182
|
+
"@brightweblabs/theme": "^0.5.1",
|
|
183
|
+
"@brightweblabs/ui": "^1.2.0",
|
|
183
184
|
"geist": "1.7.2",
|
|
184
185
|
"lucide-react": "^1.8.0",
|
|
185
186
|
"next": "^16.0.0",
|
package/src/diff.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { stdout as output } from "node:process";
|
|
|
4
4
|
import { findWorkspaceRoot, readAppManifest } from "./app-manifest.mjs";
|
|
5
5
|
import { pathExists } from "./generator.mjs";
|
|
6
6
|
import { findTrackedTemplate, scaffoldDrift } from "./scaffold.mjs";
|
|
7
|
+
import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
|
|
7
8
|
|
|
8
9
|
const HELP = `Usage: bw diff <relpath> [options]\n bw diff --list [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --list Print tracked scaffold drift status\n --help Show this help`;
|
|
9
10
|
|
|
@@ -58,8 +59,7 @@ export async function diffBrightwebScaffold(relativePath, argvOptions = {}, runt
|
|
|
58
59
|
return { list: true, drift };
|
|
59
60
|
}
|
|
60
61
|
if (!relativePath) throw new Error("bw diff requires a tracked scaffold <relpath>, or pass --list.");
|
|
61
|
-
const normalized =
|
|
62
|
-
if (path.isAbsolute(relativePath) || normalized.startsWith(`..${path.sep}`)) throw new Error(`Scaffold path must be relative to the app: ${relativePath}`);
|
|
62
|
+
const normalized = normalizeSafeRelativePath(relativePath, "Scaffold path");
|
|
63
63
|
const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
|
|
64
64
|
const located = await findTrackedTemplate({ relativePath: normalized, manifest, targetDir, workspaceRoot });
|
|
65
65
|
if (!located.record) throw new Error(`${normalized} is not a tracked scaffold file.`);
|
|
@@ -68,7 +68,7 @@ export async function diffBrightwebScaffold(relativePath, argvOptions = {}, runt
|
|
|
68
68
|
output.write(`${warning}\n`);
|
|
69
69
|
return { supported: false, warning };
|
|
70
70
|
}
|
|
71
|
-
const appPath =
|
|
71
|
+
const appPath = resolveSafeRelativePath(targetDir, normalized, "Scaffold path");
|
|
72
72
|
const [templateContent, appContent] = await Promise.all([
|
|
73
73
|
fs.readFile(located.templatePath, "utf8"),
|
|
74
74
|
pathExists(appPath) ? fs.readFile(appPath, "utf8") : Promise.resolve(""),
|
package/src/generator.mjs
CHANGED
|
@@ -839,7 +839,7 @@ export function createPackageJson({
|
|
|
839
839
|
version: "0.0.0",
|
|
840
840
|
scripts: {
|
|
841
841
|
dev: "next dev",
|
|
842
|
-
build: "next build",
|
|
842
|
+
build: "next build --webpack",
|
|
843
843
|
start: "next start",
|
|
844
844
|
lint: "tsc --noEmit",
|
|
845
845
|
},
|
|
@@ -897,6 +897,7 @@ export async function createPlatformGlobalsCss(selectedModules) {
|
|
|
897
897
|
const sourcePackages = [
|
|
898
898
|
"@brightweblabs/ui",
|
|
899
899
|
"@brightweblabs/app-shell",
|
|
900
|
+
"@brightweblabs/core-auth",
|
|
900
901
|
...SELECTABLE_MODULES
|
|
901
902
|
.filter((moduleDefinition) => selectedModules.includes(moduleDefinition.key))
|
|
902
903
|
.map((moduleDefinition) => moduleDefinition.packageName),
|
|
@@ -1018,6 +1019,211 @@ export function createShellConfig(selectedModules) {
|
|
|
1018
1019
|
].join("\n");
|
|
1019
1020
|
}
|
|
1020
1021
|
|
|
1022
|
+
export function createModuleToolbarControlsConfig(selectedModules) {
|
|
1023
|
+
const imports = [];
|
|
1024
|
+
const branches = [];
|
|
1025
|
+
|
|
1026
|
+
if (selectedModules.includes("admin")) {
|
|
1027
|
+
imports.push('import { AdminToolbarControls } from "@brightweblabs/module-admin/ui";');
|
|
1028
|
+
branches.push(' if (pathname === "/admin/users") return <AdminToolbarControls />;');
|
|
1029
|
+
}
|
|
1030
|
+
if (selectedModules.includes("crm")) {
|
|
1031
|
+
imports.push('import { CrmToolbarControls } from "@brightweblabs/module-crm/ui";');
|
|
1032
|
+
branches.push(' if (pathname === "/crm") return <CrmToolbarControls />;');
|
|
1033
|
+
}
|
|
1034
|
+
if (selectedModules.includes("projects")) {
|
|
1035
|
+
imports.push('import { ProjectsToolbarControls } from "@brightweblabs/module-projects/ui";');
|
|
1036
|
+
branches.push(' if (pathname === projectsBaseHref) return <ProjectsToolbarControls />;');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return [
|
|
1040
|
+
'"use client";',
|
|
1041
|
+
"",
|
|
1042
|
+
"// MANAGED BY BRIGHTWEB — regenerated when modules are added, removed, or updated.",
|
|
1043
|
+
...imports,
|
|
1044
|
+
imports.length > 0 ? "" : null,
|
|
1045
|
+
"export function getModuleToolbarControls(pathname: string, projectsBaseHref: string) {",
|
|
1046
|
+
...branches,
|
|
1047
|
+
" return null;",
|
|
1048
|
+
"}",
|
|
1049
|
+
"",
|
|
1050
|
+
].filter((line) => line !== null).join("\n");
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function createOrganizationRoute(methods, enabled) {
|
|
1054
|
+
const lines = ['export const dynamic = "force-dynamic";', ""];
|
|
1055
|
+
for (const { method, handler, context = false } of methods) {
|
|
1056
|
+
if (enabled) {
|
|
1057
|
+
lines.push(
|
|
1058
|
+
`export async function ${method}(request: Request${context ? ', context: { params: Promise<{ id: string }> }' : ""}) {`,
|
|
1059
|
+
` const { ${handler} } = await import("@brightweblabs/module-orgs");`,
|
|
1060
|
+
` return ${handler}(request${context ? ", context" : ""});`,
|
|
1061
|
+
"}",
|
|
1062
|
+
"",
|
|
1063
|
+
);
|
|
1064
|
+
} else {
|
|
1065
|
+
lines.push(
|
|
1066
|
+
`export async function ${method}(_request: Request${context ? ', _context: { params: Promise<{ id: string }> }' : ""}) {`,
|
|
1067
|
+
' return new Response(null, { status: 404 });',
|
|
1068
|
+
"}",
|
|
1069
|
+
"",
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return lines.join("\n");
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
export function createOptionalModuleRouteFiles(selectedModules) {
|
|
1077
|
+
const adminEnabled = selectedModules.includes("admin");
|
|
1078
|
+
const crmEnabled = selectedModules.includes("crm");
|
|
1079
|
+
const orgsEnabled = selectedModules.includes("orgs")
|
|
1080
|
+
|| crmEnabled
|
|
1081
|
+
|| selectedModules.includes("marketing")
|
|
1082
|
+
|| selectedModules.includes("projects");
|
|
1083
|
+
const dependencyImports = [
|
|
1084
|
+
'import { requireServerUserAccess } from "@brightweblabs/core-auth/server";',
|
|
1085
|
+
'import type { InvitationHttpDependencies } from "@brightweblabs/core-auth/routes";',
|
|
1086
|
+
'import { requireServiceRoleClient } from "@brightweblabs/infra/server";',
|
|
1087
|
+
];
|
|
1088
|
+
|
|
1089
|
+
if (adminEnabled) {
|
|
1090
|
+
dependencyImports.push(
|
|
1091
|
+
'import { getAdminUserInvitationDetails, registerUserFromAdminInvitation } from "@brightweblabs/module-admin";',
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
if (orgsEnabled) {
|
|
1095
|
+
dependencyImports.push(
|
|
1096
|
+
'import { acceptOrganizationInvitation, getOrganizationInvitationDetails, registerUserFromOrganizationInvitation } from "@brightweblabs/module-orgs";',
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
if (crmEnabled) {
|
|
1100
|
+
dependencyImports.push(
|
|
1101
|
+
'import { ensureCrmContactForProfile } from "@brightweblabs/module-crm";',
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const invitationDependencies = adminEnabled && orgsEnabled && crmEnabled
|
|
1106
|
+
? [
|
|
1107
|
+
'import { requireServerUserAccess } from "@brightweblabs/core-auth/server";',
|
|
1108
|
+
'import { requireServiceRoleClient } from "@brightweblabs/infra/server";',
|
|
1109
|
+
"import {",
|
|
1110
|
+
" getAdminUserInvitationDetails,",
|
|
1111
|
+
" registerUserFromAdminInvitation,",
|
|
1112
|
+
'} from "@brightweblabs/module-admin";',
|
|
1113
|
+
'import { ensureCrmContactForProfile } from "@brightweblabs/module-crm";',
|
|
1114
|
+
"import {",
|
|
1115
|
+
" acceptOrganizationInvitation,",
|
|
1116
|
+
" getOrganizationInvitationDetails,",
|
|
1117
|
+
" registerUserFromOrganizationInvitation,",
|
|
1118
|
+
'} from "@brightweblabs/module-orgs";',
|
|
1119
|
+
"",
|
|
1120
|
+
"export const invitationHttpDependencies = {",
|
|
1121
|
+
" getServiceClient: requireServiceRoleClient,",
|
|
1122
|
+
" getAccess: requireServerUserAccess,",
|
|
1123
|
+
" getOrganizationInvitation: getOrganizationInvitationDetails,",
|
|
1124
|
+
" getAdminInvitation: getAdminUserInvitationDetails,",
|
|
1125
|
+
" registerOrganizationInvitation: (client: never, input: {",
|
|
1126
|
+
" invitationId: string;",
|
|
1127
|
+
" firstName: string;",
|
|
1128
|
+
" lastName: string;",
|
|
1129
|
+
" password: string;",
|
|
1130
|
+
" }) => registerUserFromOrganizationInvitation(client, {",
|
|
1131
|
+
" ...input,",
|
|
1132
|
+
" ensureCrmContactForProfile,",
|
|
1133
|
+
" }),",
|
|
1134
|
+
" registerAdminInvitation: registerUserFromAdminInvitation,",
|
|
1135
|
+
" acceptOrganizationInvitation: (client: never, input: {",
|
|
1136
|
+
" invitationId: string;",
|
|
1137
|
+
" profileId: string;",
|
|
1138
|
+
" userEmail: string;",
|
|
1139
|
+
" }) => acceptOrganizationInvitation(client, {",
|
|
1140
|
+
" ...input,",
|
|
1141
|
+
" ensureCrmContactForProfile,",
|
|
1142
|
+
" }),",
|
|
1143
|
+
"};",
|
|
1144
|
+
"",
|
|
1145
|
+
].join("\n")
|
|
1146
|
+
: [
|
|
1147
|
+
...dependencyImports,
|
|
1148
|
+
"",
|
|
1149
|
+
...(!adminEnabled ? [
|
|
1150
|
+
"const getAdminUserInvitationDetails = async (_client: never, _invitationId: string) => null;",
|
|
1151
|
+
'const registerUserFromAdminInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("INVITATION_NOT_FOUND"); };',
|
|
1152
|
+
] : []),
|
|
1153
|
+
...(!orgsEnabled ? [
|
|
1154
|
+
"const getOrganizationInvitationDetails = async (_client: never, _invitationId: string) => null;",
|
|
1155
|
+
'const registerUserFromOrganizationInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("INVITATION_NOT_FOUND"); };',
|
|
1156
|
+
'const acceptOrganizationInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("Convite não encontrado."); };',
|
|
1157
|
+
] : []),
|
|
1158
|
+
...(!crmEnabled ? [
|
|
1159
|
+
"const ensureCrmContactForProfile = async () => ({ success: true as const });",
|
|
1160
|
+
] : []),
|
|
1161
|
+
(!adminEnabled || !orgsEnabled || !crmEnabled) ? "" : null,
|
|
1162
|
+
"export const invitationHttpDependencies = {",
|
|
1163
|
+
" getServiceClient: requireServiceRoleClient,",
|
|
1164
|
+
" getAccess: requireServerUserAccess,",
|
|
1165
|
+
" getOrganizationInvitation: getOrganizationInvitationDetails,",
|
|
1166
|
+
" getAdminInvitation: getAdminUserInvitationDetails,",
|
|
1167
|
+
" registerOrganizationInvitation: (client: never, input: {",
|
|
1168
|
+
" invitationId: string;",
|
|
1169
|
+
" firstName: string;",
|
|
1170
|
+
" lastName: string;",
|
|
1171
|
+
" password: string;",
|
|
1172
|
+
" }) => registerUserFromOrganizationInvitation(client, {",
|
|
1173
|
+
" ...input,",
|
|
1174
|
+
" ensureCrmContactForProfile,",
|
|
1175
|
+
" }),",
|
|
1176
|
+
" registerAdminInvitation: registerUserFromAdminInvitation,",
|
|
1177
|
+
" acceptOrganizationInvitation: (client: never, input: {",
|
|
1178
|
+
" invitationId: string;",
|
|
1179
|
+
" profileId: string;",
|
|
1180
|
+
" userEmail: string;",
|
|
1181
|
+
" }) => acceptOrganizationInvitation(client, {",
|
|
1182
|
+
" ...input,",
|
|
1183
|
+
" ensureCrmContactForProfile,",
|
|
1184
|
+
" }),",
|
|
1185
|
+
"} satisfies InvitationHttpDependencies;",
|
|
1186
|
+
"",
|
|
1187
|
+
].filter((line) => line !== null).join("\n");
|
|
1188
|
+
|
|
1189
|
+
return {
|
|
1190
|
+
"app/api/invitations/_dependencies.ts": invitationDependencies,
|
|
1191
|
+
"app/api/organizations/route.ts": createOrganizationRoute([
|
|
1192
|
+
{ method: "POST", handler: "handleOrganizationsPostRequest" },
|
|
1193
|
+
], orgsEnabled),
|
|
1194
|
+
"app/api/organizations/[id]/route.ts": createOrganizationRoute([
|
|
1195
|
+
{ method: "PATCH", handler: "handleOrganizationPatchRequest", context: true },
|
|
1196
|
+
], orgsEnabled),
|
|
1197
|
+
"app/api/organizations/[id]/invitations/route.ts": createOrganizationRoute([
|
|
1198
|
+
{ method: "GET", handler: "handleOrganizationInvitationsGetRequest", context: true },
|
|
1199
|
+
{ method: "POST", handler: "handleOrganizationInvitationsPostRequest", context: true },
|
|
1200
|
+
], orgsEnabled),
|
|
1201
|
+
"app/api/organizations/[id]/invitations/[invitationId]/route.ts": orgsEnabled
|
|
1202
|
+
? [
|
|
1203
|
+
'export const dynamic = "force-dynamic";',
|
|
1204
|
+
"",
|
|
1205
|
+
"export async function DELETE(",
|
|
1206
|
+
" request: Request,",
|
|
1207
|
+
" context: { params: Promise<{ id: string; invitationId: string }> },",
|
|
1208
|
+
") {",
|
|
1209
|
+
' const { handleOrganizationInvitationDeleteRequest } = await import("@brightweblabs/module-orgs");',
|
|
1210
|
+
" return handleOrganizationInvitationDeleteRequest(request, context);",
|
|
1211
|
+
"}",
|
|
1212
|
+
"",
|
|
1213
|
+
].join("\n")
|
|
1214
|
+
: [
|
|
1215
|
+
'export const dynamic = "force-dynamic";',
|
|
1216
|
+
"",
|
|
1217
|
+
'type RouteContext = { params: Promise<{ id: string; invitationId: string }> };',
|
|
1218
|
+
"",
|
|
1219
|
+
"export async function DELETE(_request: Request, _context: RouteContext) {",
|
|
1220
|
+
" return new Response(null, { status: 404 });",
|
|
1221
|
+
"}",
|
|
1222
|
+
"",
|
|
1223
|
+
].join("\n"),
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1021
1227
|
function createSiteConfigFile(slug) {
|
|
1022
1228
|
const siteName = titleizeSlug(slug);
|
|
1023
1229
|
|
|
@@ -1361,6 +1567,10 @@ async function scaffoldPlatformProject({
|
|
|
1361
1567
|
await ensureDirectory(path.dirname(targetDir));
|
|
1362
1568
|
await copyDirectory(baseTemplateDir, targetDir);
|
|
1363
1569
|
|
|
1570
|
+
if (!workspaceMode && packageManager === "pnpm") {
|
|
1571
|
+
await fs.writeFile(path.join(targetDir, "pnpm-workspace.yaml"), "allowBuilds:\n sharp: true\n");
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1364
1574
|
for (const moduleDefinition of SELECTABLE_MODULES) {
|
|
1365
1575
|
if (!selectedModules.includes(moduleDefinition.key)) continue;
|
|
1366
1576
|
const moduleTemplateDir = path.join(TEMPLATE_ROOT, "modules", moduleDefinition.templateFolder);
|
|
@@ -1390,7 +1600,14 @@ async function scaffoldPlatformProject({
|
|
|
1390
1600
|
createPlatformBrandConfigFile({ slug: answers.slug, brandValues }),
|
|
1391
1601
|
);
|
|
1392
1602
|
await fs.writeFile(path.join(targetDir, "config", "modules.ts"), createPlatformModulesConfigFile(selectedModules));
|
|
1603
|
+
await fs.writeFile(
|
|
1604
|
+
path.join(targetDir, "config", "module-toolbar-controls.tsx"),
|
|
1605
|
+
createModuleToolbarControlsConfig(selectedModules),
|
|
1606
|
+
);
|
|
1393
1607
|
await fs.writeFile(path.join(targetDir, "config", "shell.ts"), createShellConfig(selectedModules));
|
|
1608
|
+
for (const [relativePath, content] of Object.entries(createOptionalModuleRouteFiles(selectedModules))) {
|
|
1609
|
+
await fs.writeFile(path.join(targetDir, relativePath), content);
|
|
1610
|
+
}
|
|
1394
1611
|
await fs.writeFile(
|
|
1395
1612
|
path.join(targetDir, "docs", "ai", "app-context.json"),
|
|
1396
1613
|
createAppContextFile({
|
|
@@ -1464,6 +1681,9 @@ async function scaffoldSiteProject({
|
|
|
1464
1681
|
|
|
1465
1682
|
await ensureDirectory(path.dirname(targetDir));
|
|
1466
1683
|
await copyDirectory(baseTemplateDir, targetDir);
|
|
1684
|
+
if (!workspaceMode && packageManager === "pnpm") {
|
|
1685
|
+
await fs.writeFile(path.join(targetDir, "pnpm-workspace.yaml"), "allowBuilds:\n sharp: true\n");
|
|
1686
|
+
}
|
|
1467
1687
|
await ensureDirectory(path.join(targetDir, "config"));
|
|
1468
1688
|
await ensureDirectory(path.join(targetDir, "docs", "ai"));
|
|
1469
1689
|
|
package/src/migrations.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { resolveSafeRelativePath } from "./safe-path.mjs";
|
|
3
4
|
import { TEMPLATE_ROOT, pathExists } from "./generator.mjs";
|
|
4
5
|
|
|
5
6
|
export async function findAppMigrationsDirectory(targetDir) {
|
|
@@ -16,7 +17,9 @@ export async function findAppMigrationsDirectory(targetDir) {
|
|
|
16
17
|
export async function getModuleMigrations(moduleKey, catalogEntry = {}) {
|
|
17
18
|
const candidates = [];
|
|
18
19
|
const configuredPath = catalogEntry.manifest?.database?.migrations;
|
|
19
|
-
if (catalogEntry.packageRoot && configuredPath)
|
|
20
|
+
if (catalogEntry.packageRoot && configuredPath) {
|
|
21
|
+
candidates.push(resolveSafeRelativePath(catalogEntry.packageRoot, configuredPath, `${catalogEntry.key || "Module"} migration manifest path`));
|
|
22
|
+
}
|
|
20
23
|
if (catalogEntry.packageRoot) candidates.push(path.join(catalogEntry.packageRoot, "migrations"));
|
|
21
24
|
candidates.push(path.join(TEMPLATE_ROOT, "supabase", "modules", moduleKey, "migrations"));
|
|
22
25
|
for (const directory of candidates) {
|
package/src/remove.mjs
CHANGED
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
createAppContextFile,
|
|
14
14
|
createDbInstallPlan,
|
|
15
|
+
createModuleToolbarControlsConfig,
|
|
15
16
|
createNextConfig,
|
|
17
|
+
createOptionalModuleRouteFiles,
|
|
16
18
|
createPlatformGlobalsCss,
|
|
17
19
|
createPlatformModulesConfigFile,
|
|
18
20
|
createShellConfig,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
pathExists,
|
|
21
23
|
readJsonIfPresent,
|
|
22
24
|
} from "./generator.mjs";
|
|
25
|
+
import { resolveSafeRelativePath } from "./safe-path.mjs";
|
|
23
26
|
|
|
24
27
|
const HELP = `Usage: bw remove <moduleKey> [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --dry-run Print the removal plan without writing\n --yes Apply the removal plan\n --help Show this help`;
|
|
25
28
|
|
|
@@ -61,16 +64,18 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
|
|
|
61
64
|
const managedWrites = {
|
|
62
65
|
"next.config.ts": createNextConfig({ template: "platform", selectedModules: remainingModules }),
|
|
63
66
|
"app/globals.css": await createPlatformGlobalsCss(remainingModules),
|
|
67
|
+
"config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(remainingModules),
|
|
64
68
|
"config/modules.ts": createPlatformModulesConfigFile(remainingModules),
|
|
65
69
|
"config/shell.ts": createShellConfig(remainingModules),
|
|
66
70
|
"docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: remainingModules.filter((key) => key !== "orgs"), dbInstallPlan }),
|
|
71
|
+
...createOptionalModuleRouteFiles(remainingModules),
|
|
67
72
|
};
|
|
68
73
|
|
|
69
74
|
const cleanFiles = [];
|
|
70
75
|
const driftedFiles = [];
|
|
71
76
|
for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles || {})) {
|
|
72
77
|
if (record.module !== moduleKey) continue;
|
|
73
|
-
const filePath =
|
|
78
|
+
const filePath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
74
79
|
if (!(await pathExists(filePath))) continue;
|
|
75
80
|
if ((record.intent || "managed") === "managed" && await hashFile(filePath) === record.hash) cleanFiles.push(relativePath);
|
|
76
81
|
else driftedFiles.push(relativePath);
|
|
@@ -86,14 +91,25 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
|
|
|
86
91
|
if (!apply) return { dryRun: true, moduleKey, cleanFiles, driftedFiles, notice };
|
|
87
92
|
|
|
88
93
|
await fs.writeFile(packagePath, `${JSON.stringify(nextPackageJson, null, 2)}\n`, "utf8");
|
|
89
|
-
for (const relativePath of cleanFiles) await fs.rm(
|
|
94
|
+
for (const relativePath of cleanFiles) await fs.rm(resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path"));
|
|
90
95
|
for (const [relativePath, content] of Object.entries(managedWrites)) {
|
|
91
96
|
const targetPath = path.join(targetDir, relativePath);
|
|
92
97
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
93
98
|
await fs.writeFile(targetPath, content, "utf8");
|
|
94
99
|
}
|
|
95
100
|
delete appManifest.modules[moduleKey];
|
|
101
|
+
if (appManifest.modules.orgs) {
|
|
102
|
+
appManifest.modules.orgs.exposed = remainingModules.some((key) => ["crm", "marketing", "projects"].includes(key));
|
|
103
|
+
}
|
|
96
104
|
for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles || {})) if (record.module === moduleKey) delete appManifest.scaffoldFiles[relativePath];
|
|
105
|
+
for (const relativePath of Object.keys(managedWrites)) {
|
|
106
|
+
const record = appManifest.scaffoldFiles[relativePath];
|
|
107
|
+
if (!record) continue;
|
|
108
|
+
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
109
|
+
if (!(await pathExists(targetPath))) continue;
|
|
110
|
+
record.hash = await hashFile(targetPath);
|
|
111
|
+
record.status = "current";
|
|
112
|
+
}
|
|
97
113
|
await writeAppManifest(targetDir, appManifest);
|
|
98
114
|
output.write(`Removed ${moduleKey} package wiring and ${cleanFiles.length} clean scaffold file${cleanFiles.length === 1 ? "" : "s"}. Install dependencies next.\n`);
|
|
99
115
|
return { dryRun: false, moduleKey, cleanFiles, driftedFiles, notice };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
const WINDOWS_DRIVE_PATH = /^[A-Za-z]:/;
|
|
4
|
+
|
|
5
|
+
export function normalizeSafeRelativePath(relativePath, label = "Path") {
|
|
6
|
+
if (typeof relativePath !== "string" || relativePath.trim() === "") {
|
|
7
|
+
throw new Error(`${label} must be a non-empty relative path.`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const value = relativePath.trim();
|
|
11
|
+
if (
|
|
12
|
+
path.posix.isAbsolute(value)
|
|
13
|
+
|| path.win32.isAbsolute(value)
|
|
14
|
+
|| WINDOWS_DRIVE_PATH.test(value)
|
|
15
|
+
|| value.startsWith("\\\\")
|
|
16
|
+
) {
|
|
17
|
+
throw new Error(`${label} must be relative to the target directory: ${relativePath}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const portablePath = value.replaceAll("\\", "/");
|
|
21
|
+
if (portablePath.split("/").includes("..")) {
|
|
22
|
+
throw new Error(`${label} must not contain parent-directory traversal: ${relativePath}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const normalized = path.posix.normalize(portablePath).replace(/^\.\//, "");
|
|
26
|
+
if (normalized === "." || normalized === "") {
|
|
27
|
+
throw new Error(`${label} must identify a path inside the target directory.`);
|
|
28
|
+
}
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveSafeRelativePath(targetDir, relativePath, label = "Path") {
|
|
33
|
+
const root = path.resolve(targetDir);
|
|
34
|
+
const normalized = normalizeSafeRelativePath(relativePath, label);
|
|
35
|
+
const resolved = path.resolve(root, ...normalized.split("/"));
|
|
36
|
+
const relativeToRoot = path.relative(root, resolved);
|
|
37
|
+
if (
|
|
38
|
+
relativeToRoot === ""
|
|
39
|
+
|| relativeToRoot === ".."
|
|
40
|
+
|| relativeToRoot.startsWith(`..${path.sep}`)
|
|
41
|
+
|| path.isAbsolute(relativeToRoot)
|
|
42
|
+
) {
|
|
43
|
+
throw new Error(`${label} resolves outside the target directory: ${relativePath}`);
|
|
44
|
+
}
|
|
45
|
+
return resolved;
|
|
46
|
+
}
|
package/src/scaffold-cmd.mjs
CHANGED
|
@@ -3,17 +3,10 @@ import { stdout as output } from "node:process";
|
|
|
3
3
|
import { findWorkspaceRoot, hashFile, readAppManifest, writeAppManifest } from "./app-manifest.mjs";
|
|
4
4
|
import { pathExists } from "./generator.mjs";
|
|
5
5
|
import { findTrackedTemplate, scaffoldDrift } from "./scaffold.mjs";
|
|
6
|
+
import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
|
|
6
7
|
|
|
7
8
|
const HELP = `Usage: bw scaffold <action> [paths...] [options]\n\nActions:\n list List tracked files, live status, and intent\n own <path>... Mark existing tracked files as app-owned\n skip <path>... Mark missing tracked files as intentionally absent\n manage <path>... Return tracked files to BrightWeb management\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --help Show this help`;
|
|
8
9
|
|
|
9
|
-
function normalizeTrackedPath(relativePath) {
|
|
10
|
-
const normalized = path.normalize(String(relativePath)).replace(/^\.\//, "");
|
|
11
|
-
if (path.isAbsolute(String(relativePath)) || normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
|
|
12
|
-
throw new Error(`Scaffold path must be relative to the app: ${relativePath}`);
|
|
13
|
-
}
|
|
14
|
-
return normalized;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
10
|
export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {}, runtimeOptions = {}) {
|
|
18
11
|
if (argvOptions.help || !action) { output.write(`${HELP}\n`); return { help: true }; }
|
|
19
12
|
if (!Array.isArray(paths)) paths = [paths];
|
|
@@ -30,7 +23,7 @@ export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {},
|
|
|
30
23
|
return { action, entries: live.entries };
|
|
31
24
|
}
|
|
32
25
|
|
|
33
|
-
const normalizedPaths = Array.from(new Set(paths.map(
|
|
26
|
+
const normalizedPaths = Array.from(new Set(paths.map((entry) => normalizeSafeRelativePath(entry, "Scaffold path"))));
|
|
34
27
|
for (const relativePath of normalizedPaths) {
|
|
35
28
|
if (!manifest.scaffoldFiles?.[relativePath]) throw new Error(`${relativePath} is not a tracked scaffold file.`);
|
|
36
29
|
}
|
|
@@ -47,7 +40,7 @@ export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {},
|
|
|
47
40
|
const record = manifest.scaffoldFiles[relativePath];
|
|
48
41
|
const previousIntent = record.intent || "managed";
|
|
49
42
|
const nextIntent = action === "manage" ? "managed" : action === "own" ? "owned" : "skipped";
|
|
50
|
-
const appPath =
|
|
43
|
+
const appPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
51
44
|
const exists = await pathExists(appPath);
|
|
52
45
|
if (action === "manage") {
|
|
53
46
|
const located = await findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot });
|
package/src/scaffold.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { MODULE_STARTER_FILES, PLATFORM_STARTER_FILES, SELECTABLE_MODULES } from "./constants.mjs";
|
|
5
5
|
import { hashFile } from "./app-manifest.mjs";
|
|
6
6
|
import { pathExists } from "./generator.mjs";
|
|
7
|
+
import { resolveSafeRelativePath } from "./safe-path.mjs";
|
|
7
8
|
|
|
8
9
|
const BUNDLED_TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "template");
|
|
9
10
|
|
|
@@ -42,7 +43,7 @@ export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRo
|
|
|
42
43
|
unsupported.push(definition.relativePath);
|
|
43
44
|
continue;
|
|
44
45
|
}
|
|
45
|
-
const appPath =
|
|
46
|
+
const appPath = resolveSafeRelativePath(targetDir, definition.relativePath, "Scaffold file path");
|
|
46
47
|
const templateHash = await hashFile(templatePath);
|
|
47
48
|
const exists = await pathExists(appPath);
|
|
48
49
|
records[definition.relativePath] = {
|
|
@@ -60,7 +61,7 @@ export async function scaffoldDrift(targetDir, scaffoldFiles = {}) {
|
|
|
60
61
|
const missing = [];
|
|
61
62
|
const entries = [];
|
|
62
63
|
for (const [relativePath, record] of Object.entries(scaffoldFiles)) {
|
|
63
|
-
const appPath =
|
|
64
|
+
const appPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
64
65
|
const intent = record.intent || "managed";
|
|
65
66
|
let status = "missing";
|
|
66
67
|
if (await pathExists(appPath)) {
|
package/src/update.mjs
CHANGED
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
TEMPLATE_ROOT,
|
|
14
14
|
createAppContextFile,
|
|
15
15
|
createDbInstallPlan,
|
|
16
|
+
createModuleToolbarControlsConfig,
|
|
16
17
|
createNextConfig,
|
|
18
|
+
createOptionalModuleRouteFiles,
|
|
17
19
|
createPackageJson,
|
|
18
20
|
createPlatformGlobalsCss,
|
|
19
21
|
createPlatformModulesConfigFile,
|
|
@@ -25,12 +27,19 @@ import {
|
|
|
25
27
|
readJsonIfPresent,
|
|
26
28
|
runInstall,
|
|
27
29
|
} from "./generator.mjs";
|
|
30
|
+
import { readAppManifest } from "./app-manifest.mjs";
|
|
28
31
|
|
|
29
32
|
const MANAGED_PLATFORM_FILES = [
|
|
30
33
|
"next.config.ts",
|
|
31
34
|
path.join("app", "globals.css"),
|
|
35
|
+
path.join("config", "module-toolbar-controls.tsx"),
|
|
32
36
|
path.join("config", "modules.ts"),
|
|
33
37
|
path.join("config", "shell.ts"),
|
|
38
|
+
path.join("app", "api", "invitations", "_dependencies.ts"),
|
|
39
|
+
path.join("app", "api", "organizations", "route.ts"),
|
|
40
|
+
path.join("app", "api", "organizations", "[id]", "route.ts"),
|
|
41
|
+
path.join("app", "api", "organizations", "[id]", "invitations", "route.ts"),
|
|
42
|
+
path.join("app", "api", "organizations", "[id]", "invitations", "[invitationId]", "route.ts"),
|
|
34
43
|
path.join("docs", "ai", "app-context.json"),
|
|
35
44
|
];
|
|
36
45
|
|
|
@@ -38,6 +47,14 @@ const MANAGED_SITE_FILES = [
|
|
|
38
47
|
path.join("docs", "ai", "app-context.json"),
|
|
39
48
|
];
|
|
40
49
|
|
|
50
|
+
const MODULE_SELECTED_PLATFORM_FILES = new Set([
|
|
51
|
+
path.join("app", "api", "invitations", "_dependencies.ts"),
|
|
52
|
+
path.join("app", "api", "organizations", "route.ts"),
|
|
53
|
+
path.join("app", "api", "organizations", "[id]", "route.ts"),
|
|
54
|
+
path.join("app", "api", "organizations", "[id]", "invitations", "route.ts"),
|
|
55
|
+
path.join("app", "api", "organizations", "[id]", "invitations", "[invitationId]", "route.ts"),
|
|
56
|
+
]);
|
|
57
|
+
|
|
41
58
|
function resolveUpdateTargetDirectory(runtimeOptions, argvOptions) {
|
|
42
59
|
if (runtimeOptions.targetDir || argvOptions.targetDir) {
|
|
43
60
|
return path.resolve(runtimeOptions.targetDir || argvOptions.targetDir);
|
|
@@ -264,6 +281,21 @@ async function getStarterFileStatus(targetDir, installedModules) {
|
|
|
264
281
|
continue;
|
|
265
282
|
}
|
|
266
283
|
|
|
284
|
+
// These base files are generated from the installed module set and are
|
|
285
|
+
// validated/repaired through MANAGED_PLATFORM_FILES, not raw template
|
|
286
|
+
// byte parity.
|
|
287
|
+
if (MODULE_SELECTED_PLATFORM_FILES.has(relativePath)) {
|
|
288
|
+
starterFiles.push({
|
|
289
|
+
moduleKey: "platform-base",
|
|
290
|
+
relativePath,
|
|
291
|
+
sourcePath,
|
|
292
|
+
targetPath,
|
|
293
|
+
status: "current",
|
|
294
|
+
refreshable: false,
|
|
295
|
+
});
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
267
299
|
const [sourceContent, targetContent] = await Promise.all([
|
|
268
300
|
fs.readFile(sourcePath, "utf8"),
|
|
269
301
|
fs.readFile(targetPath, "utf8"),
|
|
@@ -424,6 +456,7 @@ function renderPlanSummary(plan, options = {}) {
|
|
|
424
456
|
|
|
425
457
|
export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptions = {}) {
|
|
426
458
|
const targetDir = resolveUpdateTargetDirectory(runtimeOptions, argvOptions);
|
|
459
|
+
await readAppManifest(targetDir, { required: false });
|
|
427
460
|
const packageJsonPath = path.join(targetDir, "package.json");
|
|
428
461
|
const manifest = await readJsonIfPresent(packageJsonPath);
|
|
429
462
|
|
|
@@ -493,8 +526,10 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
|
|
|
493
526
|
const canonicalConfigFiles = {
|
|
494
527
|
"next.config.ts": createNextConfig({ template: "platform", selectedModules: installedModules }),
|
|
495
528
|
[path.join("app", "globals.css")]: await createPlatformGlobalsCss(installedModules),
|
|
529
|
+
[path.join("config", "module-toolbar-controls.tsx")]: createModuleToolbarControlsConfig(installedModules),
|
|
496
530
|
[path.join("config", "modules.ts")]: createPlatformModulesConfigFile(installedModules),
|
|
497
531
|
[path.join("config", "shell.ts")]: createShellConfig(installedModules),
|
|
532
|
+
...createOptionalModuleRouteFiles(installedModules),
|
|
498
533
|
[path.join("docs", "ai", "app-context.json")]: createAppContextFile({
|
|
499
534
|
slug: manifest.name || path.basename(targetDir),
|
|
500
535
|
template: "platform",
|
package/src/upgrade.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { hashFile, findWorkspaceRoot, loadModuleCatalog, readAppManifest, writeA
|
|
|
5
5
|
import { pathExists, runInstall } from "./generator.mjs";
|
|
6
6
|
import { applyMigrationWrites, getModuleMigrations, planMigrationAppends } from "./migrations.mjs";
|
|
7
7
|
import { buildBrightwebAppUpdatePlan } from "./update.mjs";
|
|
8
|
+
import { resolveSafeRelativePath } from "./safe-path.mjs";
|
|
8
9
|
|
|
9
10
|
const HELP = `Usage: bw upgrade [moduleKey] [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --allow-stale-fallback Use baked-in versions if npm lookup fails\n --install Install changed dependencies\n --refresh-starters Refresh unchanged starter files\n --dry-run Print the upgrade plan without writing\n --help Show this help`;
|
|
10
11
|
|
|
@@ -21,7 +22,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
21
22
|
const intentional = [];
|
|
22
23
|
for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles)) {
|
|
23
24
|
if (["owned", "skipped"].includes(record.intent)) intentional.push(relativePath);
|
|
24
|
-
const filePath =
|
|
25
|
+
const filePath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
25
26
|
if (!(await pathExists(filePath))) { missing.push(relativePath); continue; }
|
|
26
27
|
if (await hashFile(filePath) !== record.hash) drifted.push(relativePath);
|
|
27
28
|
}
|
|
@@ -60,8 +61,9 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
60
61
|
if (key && appManifest.modules[key]) appManifest.modules[key].version = cleanVersion(update.to) || appManifest.modules[key].version;
|
|
61
62
|
}
|
|
62
63
|
for (const relativePath of plan.starterFilesToRefresh || []) {
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
65
|
+
if (!protectedPaths.has(relativePath) && appManifest.scaffoldFiles[relativePath] && await pathExists(targetPath)) {
|
|
66
|
+
appManifest.scaffoldFiles[relativePath].hash = await hashFile(targetPath);
|
|
65
67
|
appManifest.scaffoldFiles[relativePath].status = "current";
|
|
66
68
|
}
|
|
67
69
|
}
|
|
@@ -7,14 +7,18 @@ import {
|
|
|
7
7
|
AppShellFrame,
|
|
8
8
|
DesktopSidebar,
|
|
9
9
|
MobileNav,
|
|
10
|
+
ShellActionsProvider,
|
|
10
11
|
computeInitials,
|
|
11
12
|
isShellNavItemActive,
|
|
13
|
+
useShellAction,
|
|
14
|
+
useShellActionDispatch,
|
|
12
15
|
useShellNavState,
|
|
13
16
|
type ShellContextualAction,
|
|
14
17
|
type ShellNavStateGroup,
|
|
15
18
|
} from "@brightweblabs/app-shell";
|
|
16
19
|
import { createAuthUiClient } from "@brightweblabs/core-auth/ui";
|
|
17
20
|
import { Toaster } from "@brightweblabs/ui";
|
|
21
|
+
import { getModuleToolbarControls } from "../../config/module-toolbar-controls";
|
|
18
22
|
import { getStarterShellConfig } from "../../config/shell";
|
|
19
23
|
import "@brightweblabs/app-shell/dashboard.css";
|
|
20
24
|
|
|
@@ -36,6 +40,17 @@ const toolbarWindowEventByAction: Record<string, string> = {
|
|
|
36
40
|
export function ShellLayoutClient({
|
|
37
41
|
children,
|
|
38
42
|
viewer,
|
|
43
|
+
}: Readonly<{ children: ReactNode; viewer: ShellViewer }>) {
|
|
44
|
+
return (
|
|
45
|
+
<ShellActionsProvider aliases={toolbarWindowEventByAction}>
|
|
46
|
+
<ShellLayoutInner viewer={viewer}>{children}</ShellLayoutInner>
|
|
47
|
+
</ShellActionsProvider>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ShellLayoutInner({
|
|
52
|
+
children,
|
|
53
|
+
viewer,
|
|
39
54
|
}: Readonly<{ children: ReactNode; viewer: ShellViewer }>) {
|
|
40
55
|
const pathname = usePathname() ?? "";
|
|
41
56
|
const router = useRouter();
|
|
@@ -72,23 +87,21 @@ export function ShellLayoutClient({
|
|
|
72
87
|
viewer.email ||
|
|
73
88
|
"Conta";
|
|
74
89
|
const projectsBaseHref = pathname.startsWith("/projects") ? "/projects" : "/projetos";
|
|
90
|
+
const toolbarControls = getModuleToolbarControls(pathname, projectsBaseHref);
|
|
75
91
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
router.push(`${projectsBaseHref}/${projectId}/${projectsBaseHref === "/projects" ? "tasks" : "tarefas"}`);
|
|
85
|
-
}
|
|
86
|
-
return;
|
|
92
|
+
const dispatchShellAction = useShellActionDispatch();
|
|
93
|
+
useShellAction("projects-back-to-portfolio", () => {
|
|
94
|
+
router.push(projectsBaseHref);
|
|
95
|
+
});
|
|
96
|
+
useShellAction("projects-open-board", () => {
|
|
97
|
+
const projectId = pathname.split("/")[2];
|
|
98
|
+
if (projectId) {
|
|
99
|
+
router.push(`${projectsBaseHref}/${projectId}/${projectsBaseHref === "/projects" ? "tasks" : "tarefas"}`);
|
|
87
100
|
}
|
|
101
|
+
});
|
|
88
102
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
103
|
+
const handleToolbarAction = (item: ShellContextualAction) => {
|
|
104
|
+
if (item.action) dispatchShellAction(item.action);
|
|
92
105
|
};
|
|
93
106
|
|
|
94
107
|
return (
|
|
@@ -143,8 +156,9 @@ export function ShellLayoutClient({
|
|
|
143
156
|
toolbarRoutes={toolbarRoutes}
|
|
144
157
|
toolbarActions={toolbarActions}
|
|
145
158
|
onToolbarAction={handleToolbarAction}
|
|
159
|
+
notifications={{}}
|
|
146
160
|
>
|
|
147
|
-
{
|
|
161
|
+
{toolbarControls}
|
|
148
162
|
</AppHeader>
|
|
149
163
|
}
|
|
150
164
|
mobileNav={
|
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import type { Metadata, Viewport } from "next";
|
|
2
3
|
import { starterBrandConfig } from "../config/brand";
|
|
3
4
|
import { ThemeProvider, ThemeScript } from "@brightweblabs/app-shell";
|
|
4
5
|
import { geistMono, geistSans } from "./fonts";
|
|
5
6
|
import "./globals.css";
|
|
6
7
|
|
|
7
|
-
export const metadata = {
|
|
8
|
+
export const metadata: Metadata = {
|
|
8
9
|
title: `${starterBrandConfig.companyName} Starter`,
|
|
9
10
|
description: starterBrandConfig.tagline,
|
|
10
11
|
};
|
|
11
12
|
|
|
13
|
+
export const viewport: Viewport = {
|
|
14
|
+
themeColor: [
|
|
15
|
+
{ media: "(prefers-color-scheme: light)", color: "white" },
|
|
16
|
+
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
12
20
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
13
21
|
return (
|
|
14
|
-
<html lang="
|
|
22
|
+
<html lang="pt-PT" className={`${geistSans.variable} ${geistMono.variable}`} suppressHydrationWarning>
|
|
15
23
|
<head>
|
|
16
|
-
<ThemeScript defaultTheme="
|
|
24
|
+
<ThemeScript defaultTheme="system" />
|
|
17
25
|
</head>
|
|
18
26
|
<body>
|
|
19
|
-
<ThemeProvider defaultTheme="
|
|
27
|
+
<ThemeProvider defaultTheme="system" disableTransitionOnChange>
|
|
20
28
|
{children}
|
|
21
29
|
</ThemeProvider>
|
|
22
30
|
</body>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
-- Removes the p_force escape hatch from `bw admin create`.
|
|
2
|
+
-- The 3-arg signature is already applied in production, so drop and recreate.
|
|
3
|
+
|
|
4
|
+
DROP FUNCTION IF EXISTS public.bootstrap_first_admin(uuid, text, boolean);
|
|
5
|
+
|
|
6
|
+
CREATE FUNCTION public.bootstrap_first_admin(
|
|
7
|
+
p_user_id uuid,
|
|
8
|
+
p_email text
|
|
9
|
+
)
|
|
10
|
+
RETURNS TABLE (
|
|
11
|
+
profile_id uuid,
|
|
12
|
+
previous_role_code text
|
|
13
|
+
)
|
|
14
|
+
LANGUAGE plpgsql
|
|
15
|
+
SECURITY DEFINER
|
|
16
|
+
SET search_path = public, auth
|
|
17
|
+
AS $$
|
|
18
|
+
DECLARE
|
|
19
|
+
v_email text;
|
|
20
|
+
v_auth_email text;
|
|
21
|
+
v_profile_id uuid;
|
|
22
|
+
v_previous_role_code text;
|
|
23
|
+
BEGIN
|
|
24
|
+
IF COALESCE(auth.jwt() ->> 'role', '') <> 'service_role' THEN
|
|
25
|
+
RAISE EXCEPTION 'bootstrap_first_admin requires service_role'
|
|
26
|
+
USING ERRCODE = '42501';
|
|
27
|
+
END IF;
|
|
28
|
+
|
|
29
|
+
v_email := NULLIF(lower(trim(COALESCE(p_email, ''))), '');
|
|
30
|
+
IF p_user_id IS NULL OR v_email IS NULL THEN
|
|
31
|
+
RAISE EXCEPTION 'A user id and email are required'
|
|
32
|
+
USING ERRCODE = '22023';
|
|
33
|
+
END IF;
|
|
34
|
+
|
|
35
|
+
SELECT lower(trim(u.email))
|
|
36
|
+
INTO v_auth_email
|
|
37
|
+
FROM auth.users u
|
|
38
|
+
WHERE u.id = p_user_id;
|
|
39
|
+
|
|
40
|
+
IF v_auth_email IS NULL OR v_auth_email <> v_email THEN
|
|
41
|
+
RAISE EXCEPTION 'Auth user and email do not match'
|
|
42
|
+
USING ERRCODE = '22023';
|
|
43
|
+
END IF;
|
|
44
|
+
|
|
45
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('brightweb:first-admin-bootstrap', 0));
|
|
46
|
+
|
|
47
|
+
IF EXISTS (
|
|
48
|
+
SELECT 1
|
|
49
|
+
FROM public.user_role_assignments ura
|
|
50
|
+
WHERE ura.role_code = 'admin'
|
|
51
|
+
) THEN
|
|
52
|
+
RAISE EXCEPTION 'A project administrator already exists'
|
|
53
|
+
USING ERRCODE = '42501';
|
|
54
|
+
END IF;
|
|
55
|
+
|
|
56
|
+
SELECT p.id
|
|
57
|
+
INTO v_profile_id
|
|
58
|
+
FROM public.profiles p
|
|
59
|
+
WHERE p.user_id = p_user_id
|
|
60
|
+
FOR UPDATE;
|
|
61
|
+
|
|
62
|
+
IF v_profile_id IS NULL THEN
|
|
63
|
+
IF EXISTS (
|
|
64
|
+
SELECT 1
|
|
65
|
+
FROM public.profiles p
|
|
66
|
+
WHERE lower(p.email) = v_email
|
|
67
|
+
) THEN
|
|
68
|
+
RAISE EXCEPTION 'A profile already exists for this email'
|
|
69
|
+
USING ERRCODE = '23505';
|
|
70
|
+
END IF;
|
|
71
|
+
|
|
72
|
+
INSERT INTO public.profiles (user_id, email)
|
|
73
|
+
VALUES (p_user_id, v_email)
|
|
74
|
+
RETURNING id INTO v_profile_id;
|
|
75
|
+
ELSE
|
|
76
|
+
UPDATE public.profiles
|
|
77
|
+
SET email = v_email,
|
|
78
|
+
updated_at = now()
|
|
79
|
+
WHERE id = v_profile_id;
|
|
80
|
+
END IF;
|
|
81
|
+
|
|
82
|
+
SELECT ura.role_code
|
|
83
|
+
INTO v_previous_role_code
|
|
84
|
+
FROM public.user_role_assignments ura
|
|
85
|
+
WHERE ura.profile_id = v_profile_id
|
|
86
|
+
FOR UPDATE;
|
|
87
|
+
|
|
88
|
+
INSERT INTO public.user_role_assignments (
|
|
89
|
+
profile_id,
|
|
90
|
+
role_code,
|
|
91
|
+
assigned_by_profile_id,
|
|
92
|
+
assigned_at,
|
|
93
|
+
reason
|
|
94
|
+
)
|
|
95
|
+
VALUES (
|
|
96
|
+
v_profile_id,
|
|
97
|
+
'admin',
|
|
98
|
+
NULL,
|
|
99
|
+
now(),
|
|
100
|
+
'bw_admin_create_bootstrap'
|
|
101
|
+
)
|
|
102
|
+
ON CONFLICT (profile_id)
|
|
103
|
+
DO UPDATE SET
|
|
104
|
+
role_code = EXCLUDED.role_code,
|
|
105
|
+
assigned_by_profile_id = NULL,
|
|
106
|
+
assigned_at = EXCLUDED.assigned_at,
|
|
107
|
+
reason = EXCLUDED.reason;
|
|
108
|
+
|
|
109
|
+
RETURN QUERY SELECT v_profile_id, v_previous_role_code;
|
|
110
|
+
END;
|
|
111
|
+
$$;
|
|
112
|
+
|
|
113
|
+
REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM PUBLIC;
|
|
114
|
+
REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM anon;
|
|
115
|
+
REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM authenticated;
|
|
116
|
+
GRANT EXECUTE ON FUNCTION public.bootstrap_first_admin(uuid, text) TO service_role;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
create or replace function public.process_marketing_resend_webhook(
|
|
2
|
+
p_provider_event_id text,
|
|
3
|
+
p_event_type text,
|
|
4
|
+
p_provider_message_id text,
|
|
5
|
+
p_payload jsonb,
|
|
6
|
+
p_occurred_at timestamptz
|
|
7
|
+
)
|
|
8
|
+
returns table (duplicate boolean, event_type text, recipient_id uuid)
|
|
9
|
+
language plpgsql
|
|
10
|
+
security definer
|
|
11
|
+
set search_path = public
|
|
12
|
+
as $$
|
|
13
|
+
declare
|
|
14
|
+
recipient public.marketing_campaign_recipients%rowtype;
|
|
15
|
+
inserted_event_id uuid;
|
|
16
|
+
suppression_reason text;
|
|
17
|
+
normalized_email text;
|
|
18
|
+
begin
|
|
19
|
+
if nullif(trim(p_provider_message_id), '') is not null then
|
|
20
|
+
select * into recipient
|
|
21
|
+
from public.marketing_campaign_recipients
|
|
22
|
+
where provider_message_id = p_provider_message_id
|
|
23
|
+
order by id
|
|
24
|
+
limit 1;
|
|
25
|
+
end if;
|
|
26
|
+
|
|
27
|
+
insert into public.marketing_message_events (
|
|
28
|
+
campaign_id,
|
|
29
|
+
recipient_id,
|
|
30
|
+
contact_id,
|
|
31
|
+
provider,
|
|
32
|
+
event_type,
|
|
33
|
+
provider_event_id,
|
|
34
|
+
payload,
|
|
35
|
+
occurred_at
|
|
36
|
+
) values (
|
|
37
|
+
recipient.campaign_id,
|
|
38
|
+
recipient.id,
|
|
39
|
+
recipient.contact_id,
|
|
40
|
+
'resend',
|
|
41
|
+
p_event_type,
|
|
42
|
+
nullif(trim(p_provider_event_id), ''),
|
|
43
|
+
p_payload,
|
|
44
|
+
coalesce(p_occurred_at, now())
|
|
45
|
+
)
|
|
46
|
+
on conflict (provider, provider_event_id) where provider_event_id is not null
|
|
47
|
+
do nothing
|
|
48
|
+
returning id into inserted_event_id;
|
|
49
|
+
|
|
50
|
+
if inserted_event_id is null then
|
|
51
|
+
return query select true, p_event_type, recipient.id;
|
|
52
|
+
return;
|
|
53
|
+
end if;
|
|
54
|
+
|
|
55
|
+
if recipient.id is not null and p_event_type in ('delivered', 'sent') then
|
|
56
|
+
update public.marketing_campaign_recipients
|
|
57
|
+
set status = 'sent',
|
|
58
|
+
sent_at = coalesce(p_occurred_at, now()),
|
|
59
|
+
error = null
|
|
60
|
+
where id = recipient.id;
|
|
61
|
+
elsif recipient.id is not null and p_event_type = 'failed' then
|
|
62
|
+
update public.marketing_campaign_recipients
|
|
63
|
+
set status = 'failed',
|
|
64
|
+
error = 'Resend reported delivery failure.'
|
|
65
|
+
where id = recipient.id;
|
|
66
|
+
elsif recipient.id is not null and p_event_type in ('bounced', 'complained', 'unsubscribed') then
|
|
67
|
+
suppression_reason := case p_event_type
|
|
68
|
+
when 'bounced' then 'bounced'
|
|
69
|
+
when 'complained' then 'complained'
|
|
70
|
+
else 'unsubscribed_all'
|
|
71
|
+
end;
|
|
72
|
+
normalized_email := lower(trim(recipient.email));
|
|
73
|
+
|
|
74
|
+
update public.marketing_campaign_recipients
|
|
75
|
+
set status = 'suppressed',
|
|
76
|
+
error = 'Resend reported ' || p_event_type || '.',
|
|
77
|
+
next_attempt_at = null
|
|
78
|
+
where id = recipient.id;
|
|
79
|
+
|
|
80
|
+
insert into public.marketing_suppressions (email, reason, source)
|
|
81
|
+
values (normalized_email, suppression_reason, 'resend_webhook')
|
|
82
|
+
on conflict (email) do update
|
|
83
|
+
set reason = excluded.reason,
|
|
84
|
+
source = excluded.source;
|
|
85
|
+
|
|
86
|
+
update public.marketing_campaign_recipients
|
|
87
|
+
set status = 'suppressed',
|
|
88
|
+
error = 'Email suppressed: ' || suppression_reason || '.',
|
|
89
|
+
next_attempt_at = null
|
|
90
|
+
where lower(trim(email)) = normalized_email
|
|
91
|
+
and status in ('queued', 'sending');
|
|
92
|
+
|
|
93
|
+
if recipient.contact_id is not null then
|
|
94
|
+
update public.marketing_subscriptions
|
|
95
|
+
set status = 'unsubscribed',
|
|
96
|
+
unsubscribed_at = coalesce(p_occurred_at, now())
|
|
97
|
+
where contact_id = recipient.contact_id;
|
|
98
|
+
end if;
|
|
99
|
+
end if;
|
|
100
|
+
|
|
101
|
+
return query select false, p_event_type, recipient.id;
|
|
102
|
+
end;
|
|
103
|
+
$$;
|
|
104
|
+
|
|
105
|
+
revoke all on function public.process_marketing_resend_webhook(text, text, text, jsonb, timestamptz) from public;
|
|
106
|
+
grant execute on function public.process_marketing_resend_webhook(text, text, text, jsonb, timestamptz) to service_role;
|