create-bw-app 0.27.1 → 0.27.2
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 +9 -1
- package/package.json +3 -2
- package/src/add.mjs +68 -19
- package/src/adopt.mjs +32 -10
- package/src/app-manifest.mjs +11 -35
- package/src/constants.mjs +8 -8
- package/src/file-policy.mjs +40 -0
- package/src/generator.mjs +22 -51
- package/src/migrations.mjs +8 -10
- package/src/mutation-paths.mjs +28 -0
- package/src/removal-dependents.mjs +32 -0
- package/src/remove.mjs +30 -29
- package/src/scaffold-cmd.mjs +7 -7
- package/src/scaffold.mjs +43 -2
- package/src/setup.mjs +6 -16
- package/src/update.mjs +42 -50
- package/src/upgrade.mjs +13 -17
- package/template/supabase/modules/admin/migrations/20260906101616_atomic_admin_invitation_acceptance.sql +50 -0
- package/template/supabase/modules/core/migrations/20260906111323_restrict_profile_identity_sync.sql +4 -0
- package/template/supabase/modules/crm/migrations/20260906101616_atomic_invitation_contact_link.sql +31 -0
- package/template/supabase/modules/marketing/migrations/20260906120900_atomic_marketing_workflow_nodes.sql +97 -0
- package/template/supabase/modules/orgs/migrations/20260906101616_atomic_organization_invitation_acceptance.sql +61 -0
- package/template/supabase/modules/orgs/migrations/20260906113855_atomic_organization_member_assignment.sql +76 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Conservative literal-import diagnostic, not a complete application bundler.
|
|
5
|
+
// Generated replacements and deliberate clean deletions are outside the surviving set.
|
|
6
|
+
export async function findSurvivingPackageImports(targetDir, packageName, excludedPaths) {
|
|
7
|
+
const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
8
|
+
const imports = new RegExp(`(?:\\bfrom\\s*|\\bimport\\s*(?:\\(\\s*)?|\\brequire\\s*\\(\\s*)["']${escaped}(?:/[^"']*)?["']`);
|
|
9
|
+
const stylesheetImports = new RegExp(`@(?:import|reference)\\s*(?:url\\(\\s*)?["']?${escaped}(?:/[^"'\\s);]+)?(?=["'\\s);]|$)`, "i");
|
|
10
|
+
const sourceExtension = /\.(?:[cm]?[jt]sx?|mdx|css)$/i;
|
|
11
|
+
const findings = [];
|
|
12
|
+
const ignored = new Set(["node_modules", ".git", ".next", ".brightweb", "supabase", "dist", "build"]);
|
|
13
|
+
async function visit(directory) {
|
|
14
|
+
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
15
|
+
if (ignored.has(entry.name)) continue;
|
|
16
|
+
const fullPath = path.join(directory, entry.name);
|
|
17
|
+
const relativePath = path.relative(targetDir, fullPath);
|
|
18
|
+
if (excludedPaths.has(relativePath)) continue;
|
|
19
|
+
if (entry.isSymbolicLink()) {
|
|
20
|
+
// Do not read outside the app or silently declare linked source safe.
|
|
21
|
+
if (entry.name.includes(".") && !sourceExtension.test(entry.name)) continue;
|
|
22
|
+
findings.push(`${relativePath} (linked source; inspect dependencies explicitly)`);
|
|
23
|
+
} else if (entry.isDirectory()) await visit(fullPath);
|
|
24
|
+
else if (sourceExtension.test(entry.name)) {
|
|
25
|
+
const source = await fs.readFile(fullPath, "utf8");
|
|
26
|
+
if ((/\.css$/i.test(entry.name) ? stylesheetImports : imports).test(source)) findings.push(relativePath);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
await visit(targetDir);
|
|
31
|
+
return findings.sort();
|
|
32
|
+
}
|
package/src/remove.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { findSurvivingPackageImports } from "./removal-dependents.mjs";
|
|
2
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
1
3
|
import fs from "node:fs/promises";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { stdout as output } from "node:process";
|
|
@@ -10,18 +12,13 @@ import {
|
|
|
10
12
|
writeAppManifest,
|
|
11
13
|
} from "./app-manifest.mjs";
|
|
12
14
|
import {
|
|
13
|
-
createAppContextFile,
|
|
14
15
|
createDbInstallPlan,
|
|
15
|
-
|
|
16
|
-
createNextConfig,
|
|
17
|
-
createOptionalModuleRouteFiles,
|
|
18
|
-
createPlatformGlobalsCss,
|
|
19
|
-
createPlatformModulesConfigFile,
|
|
20
|
-
createShellConfig,
|
|
16
|
+
createManagedPlatformFiles,
|
|
21
17
|
getDbModuleRegistry,
|
|
22
18
|
pathExists,
|
|
23
19
|
readJsonIfPresent,
|
|
24
20
|
} from "./generator.mjs";
|
|
21
|
+
import { assertNoUntrackedScaffoldWrites, preserveScaffoldDecisions, scaffoldDrift } from "./scaffold.mjs";
|
|
25
22
|
import { resolveSafeRelativePath } from "./safe-path.mjs";
|
|
26
23
|
|
|
27
24
|
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`;
|
|
@@ -38,7 +35,7 @@ function databaseNotice(moduleKey, ownedObjects) {
|
|
|
38
35
|
|
|
39
36
|
export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtimeOptions = {}) {
|
|
40
37
|
if (!moduleKey || argvOptions.help) { output.write(`${HELP}\n`); return { help: true }; }
|
|
41
|
-
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
38
|
+
const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
|
|
42
39
|
const appManifest = await readAppManifest(targetDir);
|
|
43
40
|
if (!appManifest.modules[moduleKey]) throw new Error(`Module ${moduleKey} is not installed according to .brightweb/app-manifest.json.`);
|
|
44
41
|
const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
|
|
@@ -61,34 +58,34 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
|
|
|
61
58
|
workspaceMode: Object.values(nextPackageJson.dependencies || {}).some((value) => String(value).startsWith("workspace:")),
|
|
62
59
|
registry: dbRegistry,
|
|
63
60
|
});
|
|
64
|
-
const managedWrites = {
|
|
65
|
-
"next.config.ts": createNextConfig({ template: "platform", selectedModules: remainingModules }),
|
|
66
|
-
"app/globals.css": await createPlatformGlobalsCss(remainingModules),
|
|
67
|
-
"config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(remainingModules),
|
|
68
|
-
"config/modules.ts": createPlatformModulesConfigFile(remainingModules),
|
|
69
|
-
"config/shell.ts": createShellConfig(remainingModules),
|
|
70
|
-
"docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: remainingModules.filter((key) => key !== "orgs"), dbInstallPlan }),
|
|
71
|
-
...createOptionalModuleRouteFiles(remainingModules),
|
|
72
|
-
};
|
|
61
|
+
const managedWrites = await createManagedPlatformFiles({ slug: appManifest.app.slug, selectedModules: remainingModules, dbInstallPlan });
|
|
73
62
|
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
for (const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
63
|
+
const live = await scaffoldDrift(targetDir, appManifest.scaffoldFiles);
|
|
64
|
+
const { protectedPaths } = live;
|
|
65
|
+
for (const relativePath of protectedPaths) delete managedWrites[relativePath];
|
|
66
|
+
await assertNoUntrackedScaffoldWrites({
|
|
67
|
+
targetDir,
|
|
68
|
+
scaffoldFiles: appManifest.scaffoldFiles,
|
|
69
|
+
moduleKeys: remainingModules,
|
|
70
|
+
relativePaths: Object.keys(managedWrites),
|
|
71
|
+
});
|
|
72
|
+
const retainedFiles = live.entries.filter((entry) => entry.module === moduleKey && protectedPaths.has(entry.relativePath)).map((entry) => entry.relativePath);
|
|
73
|
+
const moduleFiles = live.entries.filter((entry) => entry.module === moduleKey && entry.status !== "missing");
|
|
74
|
+
const cleanFiles = moduleFiles.filter((entry) => !protectedPaths.has(entry.relativePath)).map((entry) => entry.relativePath);
|
|
75
|
+
const driftedFiles = moduleFiles.filter((entry) => protectedPaths.has(entry.relativePath)).map((entry) => entry.relativePath);
|
|
76
|
+
await assertMutationTargets(targetDir, ["package.json", ".brightweb/app-manifest.json", ...cleanFiles, ...Object.keys(managedWrites)]);
|
|
77
|
+
const dependentsOnDisk = await findSurvivingPackageImports(targetDir, packageName, new Set([...cleanFiles, ...Object.keys(managedWrites)]));
|
|
78
|
+
if (dependentsOnDisk.length) throw new Error(`Cannot remove ${moduleKey}: surviving app files depend on ${packageName}: ${dependentsOnDisk.join(", ")}. Reconcile those imports before removal; app-owned content was not changed.`);
|
|
83
79
|
const notice = databaseNotice(moduleKey, catalog[moduleKey]?.manifest?.database?.ownedObjects || []);
|
|
84
80
|
const apply = argvOptions.yes === true && argvOptions.dryRun !== true;
|
|
85
81
|
output.write(`bw remove ${moduleKey}${apply ? "" : " (plan only; pass --yes to apply)"}\n`);
|
|
86
82
|
output.write(`Dependency to remove: ${packageName}\n`);
|
|
87
83
|
output.write(`Clean scaffold files to remove: ${cleanFiles.join(", ") || "none"}\n`);
|
|
84
|
+
output.write(`Scaffold decisions retained for re-add: ${retainedFiles.join(", ") || "none"}\n`);
|
|
88
85
|
output.write(`Drifted scaffold files left in place: ${driftedFiles.join(", ") || "none"}\n`);
|
|
89
86
|
for (const relativePath of driftedFiles) output.write(`WARN ${relativePath} is drifted and will be left in place.\n`);
|
|
90
87
|
for (const line of notice) output.write(`${line}\n`);
|
|
91
|
-
if (!apply) return { dryRun: true, moduleKey, cleanFiles, driftedFiles, notice };
|
|
88
|
+
if (!apply) return { dryRun: true, moduleKey, cleanFiles, driftedFiles, retainedFiles, notice };
|
|
92
89
|
|
|
93
90
|
await fs.writeFile(packagePath, `${JSON.stringify(nextPackageJson, null, 2)}\n`, "utf8");
|
|
94
91
|
for (const relativePath of cleanFiles) await fs.rm(resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path"));
|
|
@@ -101,7 +98,11 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
|
|
|
101
98
|
if (appManifest.modules.orgs) {
|
|
102
99
|
appManifest.modules.orgs.exposed = remainingModules.some((key) => ["crm", "marketing", "projects"].includes(key));
|
|
103
100
|
}
|
|
104
|
-
|
|
101
|
+
appManifest.scaffoldFiles = preserveScaffoldDecisions(
|
|
102
|
+
Object.fromEntries(Object.entries(appManifest.scaffoldFiles).filter(([, record]) => record.module !== moduleKey)),
|
|
103
|
+
appManifest.scaffoldFiles,
|
|
104
|
+
protectedPaths,
|
|
105
|
+
);
|
|
105
106
|
for (const relativePath of Object.keys(managedWrites)) {
|
|
106
107
|
const record = appManifest.scaffoldFiles[relativePath];
|
|
107
108
|
if (!record) continue;
|
|
@@ -112,7 +113,7 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
|
|
|
112
113
|
}
|
|
113
114
|
await writeAppManifest(targetDir, appManifest);
|
|
114
115
|
output.write(`Removed ${moduleKey} package wiring and ${cleanFiles.length} clean scaffold file${cleanFiles.length === 1 ? "" : "s"}. Install dependencies next.\n`);
|
|
115
|
-
return { dryRun: false, moduleKey, cleanFiles, driftedFiles, notice };
|
|
116
|
+
return { dryRun: false, moduleKey, cleanFiles, driftedFiles, retainedFiles, notice };
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
export { HELP as REMOVE_HELP };
|
package/src/scaffold-cmd.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { stdout as output } from "node:process";
|
|
3
4
|
import { findWorkspaceRoot, hashFile, readAppManifest, writeAppManifest } from "./app-manifest.mjs";
|
|
4
5
|
import { pathExists } from "./generator.mjs";
|
|
5
|
-
import {
|
|
6
|
+
import { canonicalScaffoldHash, scaffoldDrift } from "./scaffold.mjs";
|
|
6
7
|
import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
|
|
7
8
|
|
|
8
9
|
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`;
|
|
@@ -14,7 +15,7 @@ export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {},
|
|
|
14
15
|
if (action !== "list" && paths.length === 0) throw new Error(`bw scaffold ${action} requires at least one tracked <path>.`);
|
|
15
16
|
if (action === "list" && paths.length > 0) throw new Error("bw scaffold list does not accept file paths.");
|
|
16
17
|
|
|
17
|
-
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
18
|
+
const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
|
|
18
19
|
const manifest = await readAppManifest(targetDir);
|
|
19
20
|
const live = await scaffoldDrift(targetDir, manifest.scaffoldFiles);
|
|
20
21
|
if (action === "list") {
|
|
@@ -43,15 +44,14 @@ export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {},
|
|
|
43
44
|
const appPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
44
45
|
const exists = await pathExists(appPath);
|
|
45
46
|
if (action === "manage") {
|
|
46
|
-
const
|
|
47
|
-
if (!
|
|
48
|
-
const templateHash = await hashFile(located.templatePath);
|
|
47
|
+
const templateHash = await canonicalScaffoldHash({ relativePath, manifest, targetDir, workspaceRoot });
|
|
48
|
+
if (!templateHash) throw new Error(`Installed-package template unavailable for ${relativePath}; cannot manage it safely.`);
|
|
49
49
|
if (exists) {
|
|
50
|
-
record.
|
|
51
|
-
record.status = record.hash === templateHash ? "current" : "drifted";
|
|
50
|
+
record.status = await hashFile(appPath) === templateHash ? "current" : "drifted";
|
|
52
51
|
} else {
|
|
53
52
|
record.status = "missing";
|
|
54
53
|
}
|
|
54
|
+
record.hash = templateHash;
|
|
55
55
|
} else {
|
|
56
56
|
record.status = liveByPath.get(relativePath).status;
|
|
57
57
|
}
|
package/src/scaffold.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isAppOwnedSeed } from "./file-policy.mjs";
|
|
2
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
1
4
|
import fs from "node:fs/promises";
|
|
2
5
|
import path from "node:path";
|
|
3
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -34,7 +37,22 @@ export function trackedScaffoldDefinitions(moduleKeys = []) {
|
|
|
34
37
|
return definitions.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
/** Missing ownership history is not permission to replace an existing scaffold. */
|
|
41
|
+
export async function assertNoUntrackedScaffoldWrites({ targetDir, scaffoldFiles, moduleKeys, relativePaths }) {
|
|
42
|
+
const tracked = new Set(trackedScaffoldDefinitions(moduleKeys).map((entry) => entry.relativePath));
|
|
43
|
+
for (const relativePath of relativePaths) {
|
|
44
|
+
if (!tracked.has(relativePath) || scaffoldFiles[relativePath]) continue;
|
|
45
|
+
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Scaffold output path");
|
|
46
|
+
const exists = await fs.lstat(targetPath).then(() => true, (error) => {
|
|
47
|
+
if (error.code === "ENOENT") return false;
|
|
48
|
+
throw error;
|
|
49
|
+
});
|
|
50
|
+
if (exists) throw new Error(`Untracked app file conflicts with scaffold output: ${relativePath}. Move or reconcile it explicitly before continuing.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRoot }) {
|
|
55
|
+
await assertMutationTargets(targetDir, trackedScaffoldDefinitions(moduleKeys).map((entry) => entry.relativePath));
|
|
38
56
|
const records = {};
|
|
39
57
|
const unsupported = [];
|
|
40
58
|
for (const definition of trackedScaffoldDefinitions(moduleKeys)) {
|
|
@@ -47,6 +65,7 @@ export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRo
|
|
|
47
65
|
const templateHash = await hashFile(templatePath);
|
|
48
66
|
const exists = await pathExists(appPath);
|
|
49
67
|
records[definition.relativePath] = {
|
|
68
|
+
...(isAppOwnedSeed(definition.relativePath) ? { intent: "owned" } : {}),
|
|
50
69
|
module: definition.moduleKey,
|
|
51
70
|
hash: templateHash,
|
|
52
71
|
status: !exists ? "missing" : await hashFile(appPath) === templateHash ? "current" : "drifted",
|
|
@@ -55,14 +74,26 @@ export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRo
|
|
|
55
74
|
return { records, unsupported };
|
|
56
75
|
}
|
|
57
76
|
|
|
77
|
+
/** Ownership belongs to an exact app path, independently of installed modules. */
|
|
78
|
+
export function preserveScaffoldDecisions(records, previousRecords = {}, protectedPaths = new Set()) {
|
|
79
|
+
const reconciled = { ...records };
|
|
80
|
+
for (const [relativePath, record] of Object.entries(previousRecords)) {
|
|
81
|
+
if (["owned", "skipped"].includes(record.intent) || protectedPaths.has(relativePath)) {
|
|
82
|
+
reconciled[relativePath] = { ...record };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return reconciled;
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
export async function scaffoldDrift(targetDir, scaffoldFiles = {}) {
|
|
89
|
+
await assertMutationTargets(targetDir, Object.keys(scaffoldFiles));
|
|
59
90
|
const current = [];
|
|
60
91
|
const drifted = [];
|
|
61
92
|
const missing = [];
|
|
62
93
|
const entries = [];
|
|
63
94
|
for (const [relativePath, record] of Object.entries(scaffoldFiles)) {
|
|
64
95
|
const appPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
65
|
-
const intent = record.intent || "managed";
|
|
96
|
+
const intent = record.intent === "skipped" ? "skipped" : isAppOwnedSeed(relativePath) ? "owned" : record.intent || "managed";
|
|
66
97
|
let status = "missing";
|
|
67
98
|
if (await pathExists(appPath)) {
|
|
68
99
|
const matchesRecordedHash = await hashFile(appPath) === record.hash;
|
|
@@ -73,7 +104,9 @@ export async function scaffoldDrift(targetDir, scaffoldFiles = {}) {
|
|
|
73
104
|
else if (status === "current") current.push(relativePath);
|
|
74
105
|
else drifted.push(relativePath);
|
|
75
106
|
}
|
|
76
|
-
|
|
107
|
+
const intentional = entries.filter((entry) => entry.intent !== "managed").map((entry) => entry.relativePath);
|
|
108
|
+
const protectedPaths = new Set([...drifted, ...intentional]);
|
|
109
|
+
return { current, drifted, missing, entries, intentional, protectedPaths };
|
|
77
110
|
}
|
|
78
111
|
|
|
79
112
|
export async function findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot }) {
|
|
@@ -89,3 +122,11 @@ export async function findTrackedTemplate({ relativePath, manifest, targetDir, w
|
|
|
89
122
|
export async function readTextFile(filePath) {
|
|
90
123
|
return fs.readFile(filePath, "utf8");
|
|
91
124
|
}
|
|
125
|
+
|
|
126
|
+
export async function canonicalScaffoldHash({ relativePath, manifest, targetDir, workspaceRoot }) {
|
|
127
|
+
const { createOptionalModuleRouteFiles } = await import("./generator.mjs");
|
|
128
|
+
const generated = createOptionalModuleRouteFiles(Object.keys(manifest.modules || {}))[relativePath];
|
|
129
|
+
if (generated != null) return `sha256:${createHash("sha256").update(generated).digest("hex")}`;
|
|
130
|
+
const located = await findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot });
|
|
131
|
+
return located.templatePath ? hashFile(located.templatePath) : null;
|
|
132
|
+
}
|
package/src/setup.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { createRequire } from "node:module";
|
|
4
5
|
import { readAppManifest, readConfiguredModuleFlags } from "./app-manifest.mjs";
|
|
@@ -26,23 +27,12 @@ export default function Page() {
|
|
|
26
27
|
}
|
|
27
28
|
`;
|
|
28
29
|
|
|
29
|
-
// Generated paths must remain real directories/files inside the app, even when
|
|
30
|
-
// an intermediate directory or an existing target is a symlink.
|
|
31
30
|
async function inspectTarget(root, relativePath) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
try { stat = await fs.lstat(current); } catch (error) {
|
|
38
|
-
if (error.code === "ENOENT") return null;
|
|
39
|
-
throw error;
|
|
40
|
-
}
|
|
41
|
-
if (stat.isSymbolicLink()) throw new Error(`Setup does not follow symlinks: ${relativePath}`);
|
|
42
|
-
if (index < parts.length - 1 && !stat.isDirectory()) throw new Error(`Expected a directory at ${current}`);
|
|
43
|
-
if (index === parts.length - 1 && !stat.isFile()) throw new Error(`Expected a file at ${relativePath}`);
|
|
44
|
-
}
|
|
45
|
-
return fs.readFile(current, "utf8");
|
|
31
|
+
await assertMutationTargets(root, [relativePath]);
|
|
32
|
+
return fs.readFile(path.join(root, relativePath), "utf8").catch((error) => {
|
|
33
|
+
if (error.code === "ENOENT") return null;
|
|
34
|
+
throw error;
|
|
35
|
+
});
|
|
46
36
|
}
|
|
47
37
|
|
|
48
38
|
function navigationSource(source) {
|
package/src/update.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
2
|
+
import { MANAGED_PLATFORM_FILES, MANAGED_SITE_FILES, MODULE_SELECTED_FILES, isAppOwnedSeed } from "./file-policy.mjs";
|
|
1
3
|
import fs from "node:fs/promises";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { stdout as output } from "node:process";
|
|
@@ -13,13 +15,8 @@ import {
|
|
|
13
15
|
TEMPLATE_ROOT,
|
|
14
16
|
createAppContextFile,
|
|
15
17
|
createDbInstallPlan,
|
|
16
|
-
|
|
17
|
-
createNextConfig,
|
|
18
|
-
createOptionalModuleRouteFiles,
|
|
18
|
+
createManagedPlatformFiles,
|
|
19
19
|
createPackageJson,
|
|
20
|
-
createPlatformGlobalsCss,
|
|
21
|
-
createPlatformModulesConfigFile,
|
|
22
|
-
createShellConfig,
|
|
23
20
|
detectPackageManager,
|
|
24
21
|
getDbModuleRegistry,
|
|
25
22
|
getVersionMap,
|
|
@@ -27,33 +24,10 @@ import {
|
|
|
27
24
|
readJsonIfPresent,
|
|
28
25
|
runInstall,
|
|
29
26
|
} from "./generator.mjs";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
path.join("app", "globals.css"),
|
|
35
|
-
path.join("config", "module-toolbar-controls.tsx"),
|
|
36
|
-
path.join("config", "modules.ts"),
|
|
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"),
|
|
43
|
-
path.join("docs", "ai", "app-context.json"),
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
const MANAGED_SITE_FILES = [
|
|
47
|
-
path.join("docs", "ai", "app-context.json"),
|
|
48
|
-
];
|
|
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
|
-
]);
|
|
27
|
+
import { assertNoUntrackedScaffoldWrites, scaffoldDrift } from "./scaffold.mjs";
|
|
28
|
+
import { hashFile, writeAppManifest, readAppManifest } from "./app-manifest.mjs";
|
|
29
|
+
|
|
30
|
+
const MODULE_SELECTED_PLATFORM_FILES = new Set(MODULE_SELECTED_FILES);
|
|
57
31
|
|
|
58
32
|
const REFRESHABLE_PLATFORM_STARTER_FILES = new Set([
|
|
59
33
|
path.join("app", "api", "notifications", "route.ts"),
|
|
@@ -459,8 +433,9 @@ function renderPlanSummary(plan, options = {}) {
|
|
|
459
433
|
}
|
|
460
434
|
|
|
461
435
|
export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptions = {}) {
|
|
462
|
-
const targetDir = resolveUpdateTargetDirectory(runtimeOptions, argvOptions);
|
|
463
|
-
await readAppManifest(targetDir, { required: false });
|
|
436
|
+
const targetDir = await fs.realpath(resolveUpdateTargetDirectory(runtimeOptions, argvOptions));
|
|
437
|
+
const appManifest = await readAppManifest(targetDir, { required: false });
|
|
438
|
+
await assertMutationTargets(targetDir, ["package.json", ...Object.keys(appManifest?.scaffoldFiles || {})]);
|
|
464
439
|
const packageJsonPath = path.join(targetDir, "package.json");
|
|
465
440
|
const manifest = await readJsonIfPresent(packageJsonPath);
|
|
466
441
|
|
|
@@ -527,20 +502,7 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
|
|
|
527
502
|
}
|
|
528
503
|
|
|
529
504
|
if (template === "platform") {
|
|
530
|
-
const canonicalConfigFiles = {
|
|
531
|
-
"next.config.ts": createNextConfig({ template: "platform", selectedModules: installedModules }),
|
|
532
|
-
[path.join("app", "globals.css")]: await createPlatformGlobalsCss(installedModules),
|
|
533
|
-
[path.join("config", "module-toolbar-controls.tsx")]: createModuleToolbarControlsConfig(installedModules),
|
|
534
|
-
[path.join("config", "modules.ts")]: createPlatformModulesConfigFile(installedModules),
|
|
535
|
-
[path.join("config", "shell.ts")]: createShellConfig(installedModules),
|
|
536
|
-
...createOptionalModuleRouteFiles(installedModules),
|
|
537
|
-
[path.join("docs", "ai", "app-context.json")]: createAppContextFile({
|
|
538
|
-
slug: manifest.name || path.basename(targetDir),
|
|
539
|
-
template: "platform",
|
|
540
|
-
selectedModules: installedModules,
|
|
541
|
-
dbInstallPlan,
|
|
542
|
-
}),
|
|
543
|
-
};
|
|
505
|
+
const canonicalConfigFiles = await createManagedPlatformFiles({ slug: manifest.name || path.basename(targetDir), selectedModules: installedModules, dbInstallPlan });
|
|
544
506
|
|
|
545
507
|
for (const relativePath of MANAGED_PLATFORM_FILES) {
|
|
546
508
|
const targetPath = path.join(targetDir, relativePath);
|
|
@@ -588,7 +550,8 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
|
|
|
588
550
|
|
|
589
551
|
if (argvOptions.refreshStarters) {
|
|
590
552
|
for (const entry of starterFiles.filter((candidate) =>
|
|
591
|
-
|
|
553
|
+
!MANAGED_PLATFORM_FILES.includes(candidate.relativePath)
|
|
554
|
+
&& candidate.status !== "current" && (candidate.status === "missing" || candidate.refreshable !== false))) {
|
|
592
555
|
fileWrites.push({
|
|
593
556
|
moduleKey: entry.moduleKey,
|
|
594
557
|
relativePath: entry.relativePath,
|
|
@@ -599,6 +562,22 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
|
|
|
599
562
|
}
|
|
600
563
|
}
|
|
601
564
|
|
|
565
|
+
// Legacy update may explicitly refresh drift, but never overrides ownership intent.
|
|
566
|
+
const liveScaffold = await scaffoldDrift(targetDir, appManifest?.scaffoldFiles);
|
|
567
|
+
const intentionalPaths = new Set(liveScaffold.intentional);
|
|
568
|
+
for (let index = fileWrites.length - 1; index >= 0; index -= 1) {
|
|
569
|
+
if (intentionalPaths.has(fileWrites[index].relativePath) || isAppOwnedSeed(fileWrites[index].relativePath)) fileWrites.splice(index, 1);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (appManifest) await assertNoUntrackedScaffoldWrites({
|
|
573
|
+
targetDir,
|
|
574
|
+
scaffoldFiles: appManifest.scaffoldFiles,
|
|
575
|
+
moduleKeys: installedModules,
|
|
576
|
+
relativePaths: fileWrites.map((entry) => entry.relativePath),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
await assertMutationTargets(targetDir, [".brightweb/app-manifest.json", ...fileWrites.map((write) => write.relativePath)]);
|
|
580
|
+
|
|
602
581
|
const modulesConfigMismatch = template === "platform"
|
|
603
582
|
? await detectModulesConfigMismatch(targetDir, installedModules)
|
|
604
583
|
: null;
|
|
@@ -613,6 +592,7 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
|
|
|
613
592
|
installedModules,
|
|
614
593
|
installedBrightwebPackages: Array.from(installedBrightwebPackagesMap.keys()).sort(),
|
|
615
594
|
targetVersions: canonicalVersions,
|
|
595
|
+
appManifest,
|
|
616
596
|
packageUpdates: packageJsonUpdate.packageUpdates,
|
|
617
597
|
configFilesToWrite: fileWrites.filter((entry) => entry.type === "config").map((entry) => entry.relativePath),
|
|
618
598
|
starterFilesMissing: starterFilesMissing.map((entry) => entry.relativePath),
|
|
@@ -648,6 +628,18 @@ export async function updateBrightwebApp(argvOptions = {}, runtimeOptions = {})
|
|
|
648
628
|
await fs.writeFile(fileWrite.targetPath, fileWrite.content, "utf8");
|
|
649
629
|
}
|
|
650
630
|
|
|
631
|
+
if (plan.appManifest) {
|
|
632
|
+
let scaffoldChanged = false;
|
|
633
|
+
for (const write of plan.fileWrites) {
|
|
634
|
+
const record = plan.appManifest.scaffoldFiles[write.relativePath];
|
|
635
|
+
if (!record) continue;
|
|
636
|
+
record.hash = await hashFile(write.targetPath);
|
|
637
|
+
record.status = "current";
|
|
638
|
+
scaffoldChanged = true;
|
|
639
|
+
}
|
|
640
|
+
if (scaffoldChanged) await writeAppManifest(plan.targetDir, plan.appManifest);
|
|
641
|
+
}
|
|
642
|
+
|
|
651
643
|
const packageJsonChanged = plan.fileWrites.some((entry) => entry.relativePath === "package.json");
|
|
652
644
|
if (argvOptions.install && packageJsonChanged) {
|
|
653
645
|
const installRunner = runtimeOptions.installRunner || runInstall;
|
package/src/upgrade.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertMutationTargets } from "./mutation-paths.mjs";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { stdout as output } from "node:process";
|
|
@@ -6,6 +7,7 @@ import { pathExists, runInstall } from "./generator.mjs";
|
|
|
6
7
|
import { applyMigrationWrites, getModuleMigrations, planMigrationAppends } from "./migrations.mjs";
|
|
7
8
|
import { buildBrightwebAppUpdatePlan } from "./update.mjs";
|
|
8
9
|
import { resolveSafeRelativePath } from "./safe-path.mjs";
|
|
10
|
+
import { scaffoldDrift } from "./scaffold.mjs";
|
|
9
11
|
import { RETIRED_MODULE_STARTER_FILES } from "./constants.mjs";
|
|
10
12
|
|
|
11
13
|
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 --through-migration <file> Advance the named module only through this migration\n --include-destructive-migrations Explicitly include held destructive migrations\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`;
|
|
@@ -25,36 +27,30 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
25
27
|
if (includeDestructiveMigrations && !moduleKey) {
|
|
26
28
|
throw new Error("--include-destructive-migrations requires an explicit module key so destructive scope cannot expand implicitly.");
|
|
27
29
|
}
|
|
28
|
-
const targetDir = path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd());
|
|
30
|
+
const targetDir = await fs.realpath(path.resolve(runtimeOptions.targetDir || argvOptions.targetDir || process.cwd()));
|
|
29
31
|
const appManifest = await readAppManifest(targetDir);
|
|
30
32
|
if (moduleKey && !appManifest.modules[moduleKey]) throw new Error(`Module ${moduleKey} is not installed according to ${path.join(".brightweb", "app-manifest.json")}.`);
|
|
31
33
|
const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
|
|
32
34
|
const updateOptions = { ...argvOptions, targetDir, ...(workspaceRoot ? { workspaceRoot } : {}) };
|
|
33
35
|
const plan = await buildBrightwebAppUpdatePlan(updateOptions, runtimeOptions);
|
|
34
|
-
const
|
|
35
|
-
const missing =
|
|
36
|
-
const intentional = [];
|
|
37
|
-
for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles)) {
|
|
38
|
-
if (["owned", "skipped"].includes(record.intent)) intentional.push(relativePath);
|
|
39
|
-
const filePath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
40
|
-
if (!(await pathExists(filePath))) { missing.push(relativePath); continue; }
|
|
41
|
-
if (await hashFile(filePath) !== record.hash) drifted.push(relativePath);
|
|
42
|
-
}
|
|
43
|
-
const protectedPaths = new Set([...drifted, ...intentional]);
|
|
36
|
+
const live = await scaffoldDrift(targetDir, appManifest.scaffoldFiles);
|
|
37
|
+
const { drifted, missing, intentional, protectedPaths } = live;
|
|
44
38
|
const obsoleteScaffoldFiles = [];
|
|
45
39
|
if (argvOptions.refreshStarters) {
|
|
40
|
+
const currentPaths = new Set(live.current);
|
|
46
41
|
for (const moduleKey of Object.keys(appManifest.modules)) {
|
|
47
42
|
for (const relativePath of RETIRED_MODULE_STARTER_FILES[moduleKey] ?? []) {
|
|
48
43
|
const record = appManifest.scaffoldFiles[relativePath];
|
|
49
44
|
if (!record || protectedPaths.has(relativePath)) continue;
|
|
50
45
|
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Obsolete scaffold file path");
|
|
51
|
-
if (!(
|
|
46
|
+
if (!currentPaths.has(relativePath)) continue;
|
|
52
47
|
obsoleteScaffoldFiles.push({ relativePath, targetPath });
|
|
53
48
|
}
|
|
54
49
|
}
|
|
55
50
|
}
|
|
56
51
|
plan.fileDeletes = obsoleteScaffoldFiles;
|
|
57
|
-
plan.fileWrites = plan.fileWrites.filter((entry) =>
|
|
52
|
+
plan.fileWrites = plan.fileWrites.filter((entry) => !protectedPaths.has(entry.relativePath));
|
|
53
|
+
plan.configFilesToWrite = plan.fileWrites.filter((entry) => entry.type === "config").map((entry) => entry.relativePath);
|
|
58
54
|
plan.starterFilesToRefresh = plan.fileWrites.filter((entry) => entry.type === "starter").map((entry) => entry.relativePath);
|
|
59
55
|
plan.starterFilesDrifted = Array.from(new Set([...plan.starterFilesDrifted, ...drifted]));
|
|
60
56
|
plan.starterFilesMissing = Array.from(new Set([...plan.starterFilesMissing, ...missing]));
|
|
@@ -81,7 +77,8 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
81
77
|
const safetyBoundaries = [];
|
|
82
78
|
for (const key of moduleKeys) {
|
|
83
79
|
const migrations = await getModuleMigrations(key, catalog[key]);
|
|
84
|
-
const
|
|
80
|
+
const cursorIndex = migrations.findIndex((entry) => entry.fileName === appManifest.migrationCursor?.[key]);
|
|
81
|
+
const destructiveIndex = migrations.findIndex((entry, index) => index > cursorIndex && entry.destructive);
|
|
85
82
|
if (throughMigration && key === moduleKey) {
|
|
86
83
|
const cutoffIndex = migrations.findIndex((entry) => entry.fileName === throughMigration);
|
|
87
84
|
if (cutoffIndex >= destructiveIndex && destructiveIndex >= 0 && !includeDestructiveMigrations) {
|
|
@@ -91,8 +88,6 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
91
88
|
continue;
|
|
92
89
|
}
|
|
93
90
|
if (includeDestructiveMigrations || destructiveIndex < 0) continue;
|
|
94
|
-
const cursorIndex = migrations.findIndex((entry) => entry.fileName === appManifest.migrationCursor?.[key]);
|
|
95
|
-
if (cursorIndex >= destructiveIndex) continue;
|
|
96
91
|
if (destructiveIndex === 0) {
|
|
97
92
|
throw new Error(`Migration upgrade blocked: ${key} begins with destructive migration ${migrations[0].fileName}; use --include-destructive-migrations after review.`);
|
|
98
93
|
}
|
|
@@ -107,6 +102,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
107
102
|
migrationCursor: appManifest.migrationCursor,
|
|
108
103
|
migrationUpperBounds,
|
|
109
104
|
});
|
|
105
|
+
await assertMutationTargets(targetDir, [".brightweb/app-manifest.json", ...plan.fileWrites.map((write) => write.relativePath), ...plan.fileDeletes.map((entry) => entry.relativePath), ...migrationPlan.writes.map((write) => path.relative(targetDir, write.targetPath))]);
|
|
110
106
|
output.write(`bw upgrade\nPackages to update: ${plan.packageUpdates.length}\nManaged files to write: ${plan.fileWrites.length}\nObsolete managed files to remove: ${plan.fileDeletes.length}\nMigrations to append: ${migrationPlan.appends.length}\n`);
|
|
111
107
|
if (throughMigration) output.write(`Migration cutoff: ${moduleKey} through ${throughMigration}\n`);
|
|
112
108
|
for (const boundary of safetyBoundaries) {
|
|
@@ -157,7 +153,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
|
|
|
157
153
|
const existingRecord = appManifest.scaffoldFiles[relativePath];
|
|
158
154
|
if (!existingRecord && write.type !== "starter") continue;
|
|
159
155
|
const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
|
|
160
|
-
if (
|
|
156
|
+
if (protectedPaths.has(relativePath)) continue;
|
|
161
157
|
if (!(await pathExists(targetPath))) continue;
|
|
162
158
|
const hash = await hashFile(targetPath);
|
|
163
159
|
if (existingRecord) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- atomic_admin_invitation_acceptance
|
|
2
|
+
-- target: admin
|
|
3
|
+
-- created_at: 2026-09-06T10:16:16.616Z
|
|
4
|
+
|
|
5
|
+
CREATE OR REPLACE FUNCTION public.accept_admin_user_invitation(p_invitation_id uuid, p_profile_id uuid, p_user_email text)
|
|
6
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
|
|
7
|
+
DECLARE
|
|
8
|
+
v_invitation public.admin_user_invitations%ROWTYPE;
|
|
9
|
+
v_old_role text;
|
|
10
|
+
BEGIN
|
|
11
|
+
SELECT * INTO v_invitation FROM public.admin_user_invitations WHERE id = p_invitation_id FOR UPDATE;
|
|
12
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Convite não encontrado.'; END IF;
|
|
13
|
+
IF lower(btrim(p_user_email)) IS DISTINCT FROM lower(btrim(v_invitation.invited_email)) THEN
|
|
14
|
+
RAISE EXCEPTION 'Este convite pertence a outro email.';
|
|
15
|
+
END IF;
|
|
16
|
+
-- Only the service role can call this RPC; the server supplies the authenticated
|
|
17
|
+
-- user's email. Require the matching auth-linked profile without granting auth-table access.
|
|
18
|
+
PERFORM p.id FROM public.profiles p
|
|
19
|
+
WHERE p.id = p_profile_id AND p.user_id IS NOT NULL
|
|
20
|
+
AND lower(btrim(p.email)) = lower(btrim(v_invitation.invited_email))
|
|
21
|
+
FOR UPDATE OF p;
|
|
22
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Este convite pertence a outro email.'; END IF;
|
|
23
|
+
IF v_invitation.status = 'accepted' AND v_invitation.accepted_by_profile_id = p_profile_id THEN
|
|
24
|
+
RETURN jsonb_build_object('status', 'accepted', 'role', v_invitation.role_code);
|
|
25
|
+
END IF;
|
|
26
|
+
IF v_invitation.status <> 'pending' THEN RAISE EXCEPTION 'Este convite já não está disponível.'; END IF;
|
|
27
|
+
IF v_invitation.expires_at <= clock_timestamp() THEN
|
|
28
|
+
UPDATE public.admin_user_invitations SET status = 'expired' WHERE id = p_invitation_id;
|
|
29
|
+
RETURN jsonb_build_object('status', 'expired');
|
|
30
|
+
END IF;
|
|
31
|
+
|
|
32
|
+
SELECT role_code INTO v_old_role FROM public.user_role_assignments WHERE profile_id = p_profile_id FOR UPDATE;
|
|
33
|
+
INSERT INTO public.user_role_assignments(profile_id, role_code, assigned_by_profile_id, assigned_at, reason)
|
|
34
|
+
VALUES(p_profile_id, v_invitation.role_code, v_invitation.invited_by_profile_id, now(), 'Convite de utilizador do portal aceite.')
|
|
35
|
+
ON CONFLICT(profile_id) DO UPDATE SET role_code = EXCLUDED.role_code,
|
|
36
|
+
assigned_by_profile_id = EXCLUDED.assigned_by_profile_id, assigned_at = EXCLUDED.assigned_at, reason = EXCLUDED.reason;
|
|
37
|
+
IF v_invitation.invited_by_profile_id IS NOT NULL THEN
|
|
38
|
+
INSERT INTO public.role_change_audit(target_profile_id, changed_by_profile_id, old_role_code, new_role_code, reason)
|
|
39
|
+
VALUES(p_profile_id, v_invitation.invited_by_profile_id, v_old_role, v_invitation.role_code, 'Convite de utilizador do portal aceite.');
|
|
40
|
+
END IF;
|
|
41
|
+
UPDATE public.admin_user_invitations SET status = 'accepted', accepted_at = now(), accepted_by_profile_id = p_profile_id
|
|
42
|
+
WHERE id = p_invitation_id;
|
|
43
|
+
PERFORM public.log_app_activity_event('admin', 'admin_user_invitation_accepted', 'admin_user_invitations', p_invitation_id,
|
|
44
|
+
'Convite de utilizador aceite.', jsonb_build_object('email', v_invitation.invited_email, 'role', v_invitation.role_code,
|
|
45
|
+
'status', 'accepted', 'accepted_by_profile_id', p_profile_id), p_profile_id);
|
|
46
|
+
RETURN jsonb_build_object('status', 'accepted', 'role', v_invitation.role_code);
|
|
47
|
+
END;
|
|
48
|
+
$$;
|
|
49
|
+
REVOKE ALL ON FUNCTION public.accept_admin_user_invitation(uuid, uuid, text) FROM PUBLIC, anon, authenticated;
|
|
50
|
+
GRANT EXECUTE ON FUNCTION public.accept_admin_user_invitation(uuid, uuid, text) TO service_role;
|
package/template/supabase/modules/core/migrations/20260906111323_restrict_profile_identity_sync.sql
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- Profile synchronization is an internal auth bridge, not a caller-supplied identity API.
|
|
2
|
+
-- Auth triggers execute as their owner; server registration uses service_role.
|
|
3
|
+
REVOKE ALL ON FUNCTION public.sync_profile_from_auth_identity(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;
|
|
4
|
+
GRANT EXECUTE ON FUNCTION public.sync_profile_from_auth_identity(uuid, text, jsonb) TO service_role;
|
package/template/supabase/modules/crm/migrations/20260906101616_atomic_invitation_contact_link.sql
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- atomic_invitation_contact_link
|
|
2
|
+
-- target: crm
|
|
3
|
+
-- created_at: 2026-09-06T10:16:16.695Z
|
|
4
|
+
|
|
5
|
+
-- This integration is installed after orgs and is never required by an orgs-only app.
|
|
6
|
+
CREATE OR REPLACE FUNCTION public.link_organization_invitation_contact(p_profile_id uuid, p_organization_id uuid)
|
|
7
|
+
RETURNS uuid LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
|
|
8
|
+
DECLARE
|
|
9
|
+
v_profile public.profiles%ROWTYPE;
|
|
10
|
+
v_contact public.crm_contacts%ROWTYPE;
|
|
11
|
+
BEGIN
|
|
12
|
+
SELECT * INTO STRICT v_profile FROM public.profiles WHERE id = p_profile_id FOR UPDATE;
|
|
13
|
+
SELECT * INTO v_contact FROM public.crm_contacts
|
|
14
|
+
WHERE profile_id = p_profile_id OR lower(btrim(email)) = lower(btrim(v_profile.email))
|
|
15
|
+
ORDER BY (profile_id = p_profile_id) DESC NULLS LAST, id LIMIT 1 FOR UPDATE;
|
|
16
|
+
IF FOUND THEN
|
|
17
|
+
IF v_contact.profile_id IS NOT NULL AND v_contact.profile_id <> p_profile_id THEN
|
|
18
|
+
RAISE EXCEPTION 'O contacto CRM já está ligado a outro perfil.';
|
|
19
|
+
END IF;
|
|
20
|
+
UPDATE public.crm_contacts SET profile_id = p_profile_id WHERE id = v_contact.id;
|
|
21
|
+
ELSE
|
|
22
|
+
INSERT INTO public.crm_contacts(profile_id, first_name, last_name, email, status, source, organization_id)
|
|
23
|
+
VALUES(p_profile_id, v_profile.first_name, v_profile.last_name, lower(btrim(v_profile.email)), 'lead',
|
|
24
|
+
'organization_invitation_accept', p_organization_id) RETURNING * INTO v_contact;
|
|
25
|
+
END IF;
|
|
26
|
+
PERFORM public.link_crm_contact_organization(v_contact.id, p_organization_id);
|
|
27
|
+
RETURN v_contact.id;
|
|
28
|
+
END;
|
|
29
|
+
$$;
|
|
30
|
+
REVOKE ALL ON FUNCTION public.link_organization_invitation_contact(uuid, uuid) FROM PUBLIC, anon, authenticated;
|
|
31
|
+
GRANT EXECUTE ON FUNCTION public.link_organization_invitation_contact(uuid, uuid) TO service_role;
|