create-ailk 0.14.0 → 0.16.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 +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file person.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of the Person record (#852, spec
|
|
5
|
+
* docs/specs/852-person-the-per-workspace-client-spec.md D1, D2, D4, D7;
|
|
6
|
+
* AC-1, AC-2's concurrency leg, AC-4's database half and AC-6's fallback
|
|
7
|
+
* semantics). Requires DATABASE_URL pointing at a Postgres with the
|
|
8
|
+
* migrations applied — every live suite SKIPS when DATABASE_URL is unset,
|
|
9
|
+
* keeping the workspace Jest run DB-free (the repo convention; the
|
|
10
|
+
* `apps/api Route Tests (Postgres)` workflow exercises it).
|
|
11
|
+
*
|
|
12
|
+
* Uses the `pg` driver directly, as `agents.db.test.ts` does. What it proves
|
|
13
|
+
* that a mock cannot:
|
|
14
|
+
* 1. The people table's CHECKs and unique indexes (AC-1): a normalized,
|
|
15
|
+
* printable-ASCII email unique per workspace; a user unique per
|
|
16
|
+
* workspace with NULLs distinct; a NON-unique customer id; the billing
|
|
17
|
+
* pair; the id format CHECKs on people, leads.person_id and
|
|
18
|
+
* appointments.person_id.
|
|
19
|
+
* 2. INSERT … ON CONFLICT DO NOTHING absorbs a concurrent duplicate inside
|
|
20
|
+
* two open transactions: one row, one id, neither aborts (AC-2).
|
|
21
|
+
* 3. The backfill (AC-4): the committed fixture, backfilled by the
|
|
22
|
+
* migration's own text, yields exactly the expected Persons and stamps;
|
|
23
|
+
* the post-cutover script is byte-identical, changes nothing on a
|
|
24
|
+
* second run, and stamps a lead written after the first.
|
|
25
|
+
* 4. The timeline's email fallback (AC-6): the very SQL constants
|
|
26
|
+
* `apps/api/src/lib/tenant-db.ts` runs match only pre-cutoff, lead-less
|
|
27
|
+
* rows of this workspace by EXACT normalized equality — no wildcard, no
|
|
28
|
+
* non-ASCII case fold.
|
|
29
|
+
*
|
|
30
|
+
* Two cases need no database and always run: the script and the migration
|
|
31
|
+
* are byte-identical, and the normalization pairs the fixture lists are what
|
|
32
|
+
* the fixture claims (their SQL twin runs live below).
|
|
33
|
+
*/
|
|
34
|
+
import { readFileSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
|
|
37
|
+
import { Client } from "pg";
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
PERSON_BACKFILL_MIGRATION,
|
|
41
|
+
PERSON_CUTOFF_SQL,
|
|
42
|
+
PERSON_FALLBACK_FLOW_SESSIONS_SQL,
|
|
43
|
+
PERSON_FALLBACK_PROJECT_LISTINGS_SQL,
|
|
44
|
+
} from "../tenancy";
|
|
45
|
+
|
|
46
|
+
type Fixture = {
|
|
47
|
+
workspaces: Record<string, string>;
|
|
48
|
+
input: {
|
|
49
|
+
users: Array<{ key: string; email: string; emailVerified: boolean }>;
|
|
50
|
+
leads: Array<{
|
|
51
|
+
key: string;
|
|
52
|
+
workspace: string;
|
|
53
|
+
email: string;
|
|
54
|
+
payload: Record<string, unknown>;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
promotedUser?: string;
|
|
57
|
+
promotedAt?: string;
|
|
58
|
+
}>;
|
|
59
|
+
flowCheckouts: Array<{
|
|
60
|
+
key: string;
|
|
61
|
+
workspace: string;
|
|
62
|
+
lead: string;
|
|
63
|
+
status: string;
|
|
64
|
+
stripeCustomerId: string;
|
|
65
|
+
completedAt: string | null;
|
|
66
|
+
createdAt: string;
|
|
67
|
+
}>;
|
|
68
|
+
appointments: Array<{
|
|
69
|
+
key: string;
|
|
70
|
+
workspace: string;
|
|
71
|
+
attendeeEmail: string;
|
|
72
|
+
attendeeName: string;
|
|
73
|
+
createdAt: string;
|
|
74
|
+
}>;
|
|
75
|
+
subscribers: Array<{ workspace: string; email: string }>;
|
|
76
|
+
};
|
|
77
|
+
emails: Array<{ raw: string; normalized: string; storable: boolean }>;
|
|
78
|
+
expected: {
|
|
79
|
+
people: Array<{
|
|
80
|
+
workspace: string;
|
|
81
|
+
email: string;
|
|
82
|
+
name: string | null;
|
|
83
|
+
billingCustomerId: string | null;
|
|
84
|
+
user: string | null;
|
|
85
|
+
}>;
|
|
86
|
+
unstamped: string[];
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const fixture = JSON.parse(
|
|
91
|
+
readFileSync(join(__dirname, "fixtures", "person-backfill.json"), "utf8"),
|
|
92
|
+
) as Fixture;
|
|
93
|
+
|
|
94
|
+
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
95
|
+
|
|
96
|
+
const BACKFILL_MIGRATION = join(
|
|
97
|
+
__dirname,
|
|
98
|
+
"..",
|
|
99
|
+
"migrations",
|
|
100
|
+
"20261010121000_person_backfill",
|
|
101
|
+
"migration.sql",
|
|
102
|
+
);
|
|
103
|
+
const BACKFILL_SCRIPT = join(__dirname, "..", "scripts", "person-backfill.sql");
|
|
104
|
+
const PERSON_MIGRATION = join(
|
|
105
|
+
__dirname,
|
|
106
|
+
"..",
|
|
107
|
+
"migrations",
|
|
108
|
+
"20261010120000_person",
|
|
109
|
+
"migration.sql",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const N_SQL = `lower(btrim($1::text, E' \\t\\n\\r\\f\\x0B') COLLATE "C")`;
|
|
113
|
+
const ID_TAIL = "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
|
|
114
|
+
const PERSON_ID = new RegExp(`^person_${ID_TAIL}$`);
|
|
115
|
+
|
|
116
|
+
describe("the Person backfill files (#852 D2)", () => {
|
|
117
|
+
it("the post-cutover script is byte-identical to the backfill migration", () => {
|
|
118
|
+
expect(readFileSync(BACKFILL_SCRIPT, "utf8")).toBe(
|
|
119
|
+
readFileSync(BACKFILL_MIGRATION, "utf8"),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("the cutoff key names the backfill migration's folder", () => {
|
|
124
|
+
expect(PERSON_BACKFILL_MIGRATION).toBe("20261010121000_person_backfill");
|
|
125
|
+
expect(PERSON_CUTOFF_SQL).toContain(`'${PERSON_BACKFILL_MIGRATION}'`);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("the Person model carries no phone, traits, brief, merge pointer or secondary email", () => {
|
|
129
|
+
const schema = readFileSync(join(__dirname, "..", "inbox", "schema.prisma"), "utf8");
|
|
130
|
+
const block = /^model Person \{[\s\S]*?^\}/m.exec(schema)?.[0] ?? "";
|
|
131
|
+
expect(block).not.toBe("");
|
|
132
|
+
expect(block).not.toMatch(/phone|traits|brief|merge|secondary/);
|
|
133
|
+
expect(readFileSync(PERSON_MIGRATION, "utf8")).not.toMatch(/"(phone|traits|brief)"/);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describeDb("the people table (#852 D1/D2, AC-1, live DB)", () => {
|
|
138
|
+
let db: Client;
|
|
139
|
+
const WS_1 = "wksp_01920000-0852-7000-8000-0000000000c1";
|
|
140
|
+
const WS_2 = "wksp_01920000-0852-7000-8000-0000000000c2";
|
|
141
|
+
const USER = "user_01920000-0852-7000-8000-0000000000c1";
|
|
142
|
+
|
|
143
|
+
beforeAll(async () => {
|
|
144
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
145
|
+
await db.connect();
|
|
146
|
+
await db.query(`DELETE FROM "people" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterAll(async () => {
|
|
150
|
+
await db.query(`DELETE FROM "people" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
|
|
151
|
+
await db.end();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
type PersonCols = {
|
|
155
|
+
id?: string;
|
|
156
|
+
workspace_id?: string;
|
|
157
|
+
email: string;
|
|
158
|
+
user_id?: string | null;
|
|
159
|
+
billing_provider?: string | null;
|
|
160
|
+
billing_customer_id?: string | null;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const insertPerson = (cols: PersonCols) =>
|
|
164
|
+
db.query<{ id: string }>(
|
|
165
|
+
`INSERT INTO "people" ("id", "workspace_id", "email", "user_id", "billing_provider", "billing_customer_id", "updated_at")
|
|
166
|
+
VALUES (COALESCE($1, 'person_' || uuidv7()::text), $2, $3, $4, $5, $6, now()) RETURNING "id"`,
|
|
167
|
+
[
|
|
168
|
+
cols.id ?? null,
|
|
169
|
+
cols.workspace_id ?? WS_1,
|
|
170
|
+
cols.email,
|
|
171
|
+
cols.user_id ?? null,
|
|
172
|
+
cols.billing_provider ?? null,
|
|
173
|
+
cols.billing_customer_id ?? null,
|
|
174
|
+
],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
it("mints a person_ id and accepts a normalized address", async () => {
|
|
178
|
+
const { rows } = await insertPerson({ email: "first@example.com" });
|
|
179
|
+
expect(rows[0]!.id).toMatch(PERSON_ID);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it.each([
|
|
183
|
+
["a mixed-case email", "Mixed@Example.com"],
|
|
184
|
+
["a leading tab", "\ttab@example.com"],
|
|
185
|
+
["a zero-width space inside", "zero\u200Bwidth@example.com"],
|
|
186
|
+
["a non-ASCII letter", "zoë@example.com"],
|
|
187
|
+
["two @", "a@b@example.com"],
|
|
188
|
+
["an empty local part", "@example.com"],
|
|
189
|
+
])("refuses %s (people_email_normalized)", async (_label, email) => {
|
|
190
|
+
await expect(insertPerson({ email })).rejects.toMatchObject({
|
|
191
|
+
code: "23514",
|
|
192
|
+
constraint: "people_email_normalized",
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("refuses a second Person with the same (workspace_id, email), admits it in another workspace", async () => {
|
|
197
|
+
await insertPerson({ email: "dup@example.com" });
|
|
198
|
+
await expect(insertPerson({ email: "dup@example.com" })).rejects.toMatchObject({
|
|
199
|
+
code: "23505",
|
|
200
|
+
constraint: "people_workspace_id_email_key",
|
|
201
|
+
});
|
|
202
|
+
await expect(insertPerson({ workspace_id: WS_2, email: "dup@example.com" })).resolves.toBeDefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("refuses two Persons of one workspace with one user_id; admits any number of NULL user_ids", async () => {
|
|
206
|
+
await insertPerson({ email: "u1@example.com", user_id: USER });
|
|
207
|
+
await expect(insertPerson({ email: "u2@example.com", user_id: USER })).rejects.toMatchObject({
|
|
208
|
+
code: "23505",
|
|
209
|
+
constraint: "people_workspace_id_user_id_key",
|
|
210
|
+
});
|
|
211
|
+
await insertPerson({ email: "null-a@example.com" });
|
|
212
|
+
await expect(insertPerson({ email: "null-b@example.com" })).resolves.toBeDefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("admits one customer id on two Persons (the spokes share one Stripe account)", async () => {
|
|
216
|
+
await insertPerson({ email: "c1@example.com", billing_provider: "stripe", billing_customer_id: "cus_shared" });
|
|
217
|
+
await expect(
|
|
218
|
+
insertPerson({ email: "c2@example.com", billing_provider: "stripe", billing_customer_id: "cus_shared" }),
|
|
219
|
+
).resolves.toBeDefined();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("refuses a customer id without a provider (people_billing_pair)", async () => {
|
|
223
|
+
await expect(
|
|
224
|
+
insertPerson({ email: "pair@example.com", billing_customer_id: "cus_alone" }),
|
|
225
|
+
).rejects.toMatchObject({ code: "23514", constraint: "people_billing_pair" });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it.each([
|
|
229
|
+
["id", { id: "lead_01920000-0852-7000-8000-0000000000c1", email: "f1@example.com" }, "people_id_format"],
|
|
230
|
+
["workspace_id", { workspace_id: "org_01920000-0852-7000-8000-0000000000c1", email: "f2@example.com" }, "people_workspace_id_format"],
|
|
231
|
+
["user_id", { user_id: "member_01920000-0852-7000-8000-0000000000c1", email: "f3@example.com" }, "people_user_id_format"],
|
|
232
|
+
] as const)("refuses a malformed %s", async (_column, cols, constraint) => {
|
|
233
|
+
await expect(insertPerson(cols)).rejects.toMatchObject({ code: "23514", constraint });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("leads.person_id and appointments.person_id accept a person_ id and refuse a lead_ one", async () => {
|
|
237
|
+
const good = "person_01920000-0852-7000-8000-0000000000c9";
|
|
238
|
+
const bad = "lead_01920000-0852-7000-8000-0000000000c9";
|
|
239
|
+
await db.query("BEGIN");
|
|
240
|
+
try {
|
|
241
|
+
await db.query(
|
|
242
|
+
`INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload", "person_id")
|
|
243
|
+
VALUES ($1, $1, 'p852-format', 'x@example.com', '{}', $2)`,
|
|
244
|
+
[WS_1, good],
|
|
245
|
+
);
|
|
246
|
+
await db.query(
|
|
247
|
+
`INSERT INTO "appointments" ("site_id", "workspace_id", "provider_booking_uid", "start_time", "end_time",
|
|
248
|
+
"attendee_name", "attendee_email", "status", "lifecycle_event_at", "updated_at", "person_id")
|
|
249
|
+
VALUES ($1::text, $1::text, 'p852-format', now(), now(), 'X', 'x@example.com', 'confirmed', now(), now(), $2)`,
|
|
250
|
+
[WS_1, good],
|
|
251
|
+
);
|
|
252
|
+
} finally {
|
|
253
|
+
await db.query("ROLLBACK");
|
|
254
|
+
}
|
|
255
|
+
await expect(
|
|
256
|
+
db.query(
|
|
257
|
+
`INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload", "person_id")
|
|
258
|
+
VALUES ($1, $1, 'p852-format', 'x@example.com', '{}', $2)`,
|
|
259
|
+
[WS_1, bad],
|
|
260
|
+
),
|
|
261
|
+
).rejects.toMatchObject({ code: "23514", constraint: "leads_person_id_format" });
|
|
262
|
+
await expect(
|
|
263
|
+
db.query(
|
|
264
|
+
`INSERT INTO "appointments" ("site_id", "workspace_id", "provider_booking_uid", "start_time", "end_time",
|
|
265
|
+
"attendee_name", "attendee_email", "status", "lifecycle_event_at", "updated_at", "person_id")
|
|
266
|
+
VALUES ($1::text, $1::text, 'p852-format', now(), now(), 'X', 'x@example.com', 'confirmed', now(), now(), $2)`,
|
|
267
|
+
[WS_1, bad],
|
|
268
|
+
),
|
|
269
|
+
).rejects.toMatchObject({ code: "23514", constraint: "appointments_person_id_format" });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("two concurrent transactions inserting one (workspace_id, email) with ON CONFLICT DO NOTHING yield one row and one id, and neither aborts (AC-2)", async () => {
|
|
273
|
+
const a = new Client({ connectionString: process.env.DATABASE_URL });
|
|
274
|
+
const b = new Client({ connectionString: process.env.DATABASE_URL });
|
|
275
|
+
await a.connect();
|
|
276
|
+
await b.connect();
|
|
277
|
+
const insert = `INSERT INTO "people" ("workspace_id", "email", "updated_at") VALUES ($1, 'race@example.com', now())
|
|
278
|
+
ON CONFLICT ("workspace_id", "email") DO NOTHING`;
|
|
279
|
+
const reread = `SELECT "id" FROM "people" WHERE "workspace_id" = $1 AND "email" = 'race@example.com'`;
|
|
280
|
+
try {
|
|
281
|
+
await a.query("BEGIN");
|
|
282
|
+
await b.query("BEGIN");
|
|
283
|
+
const insertedA = await a.query(insert, [WS_1]);
|
|
284
|
+
// B's insert waits on A's uncommitted row, then does nothing.
|
|
285
|
+
const pendingB = b.query(insert, [WS_1]);
|
|
286
|
+
const idA = (await a.query<{ id: string }>(reread, [WS_1])).rows[0]!.id;
|
|
287
|
+
await a.query("COMMIT");
|
|
288
|
+
const insertedB = await pendingB;
|
|
289
|
+
const idB = (await b.query<{ id: string }>(reread, [WS_1])).rows[0]!.id;
|
|
290
|
+
await b.query("COMMIT");
|
|
291
|
+
|
|
292
|
+
expect(insertedA.rowCount).toBe(1);
|
|
293
|
+
expect(insertedB.rowCount).toBe(0);
|
|
294
|
+
expect(idB).toBe(idA);
|
|
295
|
+
const { rows } = await db.query(reread, [WS_1]);
|
|
296
|
+
expect(rows).toHaveLength(1);
|
|
297
|
+
} finally {
|
|
298
|
+
await a.end();
|
|
299
|
+
await b.end();
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describeDb("the Person backfill (#852 D4, AC-4, live DB)", () => {
|
|
305
|
+
let db: Client;
|
|
306
|
+
const WS = fixture.workspaces;
|
|
307
|
+
const WORKSPACES = Object.values(WS);
|
|
308
|
+
const userIds = new Map<string, string>();
|
|
309
|
+
const leadIds = new Map<string, string>();
|
|
310
|
+
const appointmentIds = new Map<string, string>();
|
|
311
|
+
const userEmails = fixture.input.users.map((u) => u.email);
|
|
312
|
+
|
|
313
|
+
async function cleanup(): Promise<void> {
|
|
314
|
+
await db.query(`DELETE FROM "people" WHERE "workspace_id" = ANY($1)`, [WORKSPACES]);
|
|
315
|
+
await db.query(`DELETE FROM "flow_checkouts" WHERE "workspace_id" = ANY($1)`, [WORKSPACES]);
|
|
316
|
+
await db.query(`DELETE FROM "leads" WHERE "workspace_id" = ANY($1)`, [WORKSPACES]);
|
|
317
|
+
await db.query(`DELETE FROM "appointments" WHERE "workspace_id" = ANY($1)`, [WORKSPACES]);
|
|
318
|
+
await db.query(`DELETE FROM "subscribers" WHERE "workspace_id" = ANY($1)`, [WORKSPACES]);
|
|
319
|
+
await db.query(`DELETE FROM "users" WHERE "email" = ANY($1)`, [userEmails]);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
beforeAll(async () => {
|
|
323
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
324
|
+
await db.connect();
|
|
325
|
+
await cleanup();
|
|
326
|
+
|
|
327
|
+
for (const u of fixture.input.users) {
|
|
328
|
+
const { rows } = await db.query<{ id: string }>(
|
|
329
|
+
`INSERT INTO "users" ("email", "email_verified", "updated_at") VALUES ($1, $2, now()) RETURNING "id"`,
|
|
330
|
+
[u.email, u.emailVerified],
|
|
331
|
+
);
|
|
332
|
+
userIds.set(u.key, rows[0]!.id);
|
|
333
|
+
}
|
|
334
|
+
for (const lead of fixture.input.leads) {
|
|
335
|
+
const { rows } = await db.query<{ id: string }>(
|
|
336
|
+
`INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload", "created_at", "promoted_user_id", "promoted_at")
|
|
337
|
+
VALUES ($1, $1, 'p852', $2, $3, $4, $5, $6) RETURNING "id"`,
|
|
338
|
+
[
|
|
339
|
+
WS[lead.workspace],
|
|
340
|
+
lead.email,
|
|
341
|
+
JSON.stringify(lead.payload),
|
|
342
|
+
lead.createdAt,
|
|
343
|
+
lead.promotedUser ? userIds.get(lead.promotedUser) : null,
|
|
344
|
+
lead.promotedAt ?? null,
|
|
345
|
+
],
|
|
346
|
+
);
|
|
347
|
+
leadIds.set(lead.key, rows[0]!.id);
|
|
348
|
+
}
|
|
349
|
+
for (const c of fixture.input.flowCheckouts) {
|
|
350
|
+
await db.query(
|
|
351
|
+
`INSERT INTO "flow_checkouts" ("site_id", "workspace_id", "flow_slug", "experiment_key", "session_id", "lead_id",
|
|
352
|
+
"offer_key", "seats", "period", "credit_stop", "amount_minor", "currency", "quote",
|
|
353
|
+
"permalink", "status", "stripe_customer_id", "completed_at", "created_at", "updated_at")
|
|
354
|
+
VALUES ($1, $1, 'p852', 'p852', 'p852-session', $2, 'pro', 1, 'month', '{}', 1900, 'usd', '{}',
|
|
355
|
+
'p852-permalink', $3, $4, $5, $6, now())`,
|
|
356
|
+
[WS[c.workspace], leadIds.get(c.lead), c.status, c.stripeCustomerId, c.completedAt, c.createdAt],
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
for (const a of fixture.input.appointments) {
|
|
360
|
+
const { rows } = await db.query<{ id: string }>(
|
|
361
|
+
`INSERT INTO "appointments" ("site_id", "workspace_id", "provider_booking_uid", "start_time", "end_time",
|
|
362
|
+
"attendee_name", "attendee_email", "status", "lifecycle_event_at", "created_at", "updated_at")
|
|
363
|
+
VALUES ($1::text, $1::text, $2, $3::timestamp, $3::timestamp, $4, $5, 'confirmed', $3::timestamp, $3::timestamp, now()) RETURNING "id"`,
|
|
364
|
+
[WS[a.workspace], `p852-${a.key}`, a.createdAt, a.attendeeName, a.attendeeEmail],
|
|
365
|
+
);
|
|
366
|
+
appointmentIds.set(a.key, rows[0]!.id);
|
|
367
|
+
}
|
|
368
|
+
for (const s of fixture.input.subscribers) {
|
|
369
|
+
await db.query(
|
|
370
|
+
`INSERT INTO "subscribers" ("site_id", "workspace_id", "email", "source") VALUES ($1, $1, $2, 'p852')`,
|
|
371
|
+
[WS[s.workspace], s.email],
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
await db.query(readFileSync(BACKFILL_MIGRATION, "utf8"));
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
afterAll(async () => {
|
|
379
|
+
await cleanup();
|
|
380
|
+
await db.end();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
type PersonRow = {
|
|
384
|
+
id: string;
|
|
385
|
+
workspace_id: string;
|
|
386
|
+
email: string;
|
|
387
|
+
name: string | null;
|
|
388
|
+
billing_provider: string | null;
|
|
389
|
+
billing_customer_id: string | null;
|
|
390
|
+
user_id: string | null;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const readPeople = async () =>
|
|
394
|
+
(
|
|
395
|
+
await db.query<PersonRow>(
|
|
396
|
+
`SELECT "id", "workspace_id", "email", "name", "billing_provider", "billing_customer_id", "user_id"
|
|
397
|
+
FROM "people" WHERE "workspace_id" = ANY($1) ORDER BY "workspace_id", "email"`,
|
|
398
|
+
[WORKSPACES],
|
|
399
|
+
)
|
|
400
|
+
).rows;
|
|
401
|
+
|
|
402
|
+
const snapshot = async () => ({
|
|
403
|
+
people: (
|
|
404
|
+
await db.query(
|
|
405
|
+
`SELECT * FROM "people" WHERE "workspace_id" = ANY($1) ORDER BY "id"`,
|
|
406
|
+
[WORKSPACES],
|
|
407
|
+
)
|
|
408
|
+
).rows,
|
|
409
|
+
leads: (
|
|
410
|
+
await db.query(
|
|
411
|
+
`SELECT "id", "person_id" FROM "leads" WHERE "workspace_id" = ANY($1) ORDER BY "id"`,
|
|
412
|
+
[WORKSPACES],
|
|
413
|
+
)
|
|
414
|
+
).rows,
|
|
415
|
+
appointments: (
|
|
416
|
+
await db.query(
|
|
417
|
+
`SELECT * FROM "appointments" WHERE "workspace_id" = ANY($1) ORDER BY "id"`,
|
|
418
|
+
[WORKSPACES],
|
|
419
|
+
)
|
|
420
|
+
).rows,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("the TypeScript-side normalization pairs match the SQL N(x) and VALID(e) on every fixture email", async () => {
|
|
424
|
+
for (const e of fixture.emails) {
|
|
425
|
+
const { rows } = await db.query<{ n: string; valid: boolean }>(
|
|
426
|
+
`SELECT ${N_SQL} AS n,
|
|
427
|
+
(${N_SQL} ~ '^[!-?A-~]+@[!-?A-~]+$' AND length(${N_SQL}) <= 254) AS valid`,
|
|
428
|
+
[e.raw],
|
|
429
|
+
);
|
|
430
|
+
expect({ raw: e.raw, n: rows[0]!.n, valid: rows[0]!.valid }).toEqual({
|
|
431
|
+
raw: e.raw,
|
|
432
|
+
n: e.normalized,
|
|
433
|
+
valid: e.storable,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("makes exactly the expected Persons (email, name, customer id, user)", async () => {
|
|
439
|
+
const people = await readPeople();
|
|
440
|
+
expect(
|
|
441
|
+
people.map((p) => ({
|
|
442
|
+
workspace: Object.keys(WS).find((k) => WS[k] === p.workspace_id),
|
|
443
|
+
email: p.email,
|
|
444
|
+
name: p.name,
|
|
445
|
+
billingCustomerId: p.billing_customer_id,
|
|
446
|
+
user: p.user_id === null ? null : [...userIds].find(([, id]) => id === p.user_id)?.[0],
|
|
447
|
+
})),
|
|
448
|
+
).toEqual(fixture.expected.people);
|
|
449
|
+
for (const p of people) {
|
|
450
|
+
expect(p.billing_provider).toBe(p.billing_customer_id === null ? null : "stripe");
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("stamps every lead and appointment with a storable email with its own workspace's Person; the rest stay NULL", async () => {
|
|
455
|
+
const people = await readPeople();
|
|
456
|
+
const personOf = (workspace: string, raw: string) =>
|
|
457
|
+
people.find(
|
|
458
|
+
(p) => p.workspace_id === WS[workspace] && p.email === fixture.emails.find((e) => e.raw === raw)?.normalized,
|
|
459
|
+
)?.id ?? null;
|
|
460
|
+
for (const l of fixture.input.leads) {
|
|
461
|
+
const { rows } = await db.query<{ person_id: string | null }>(
|
|
462
|
+
`SELECT "person_id" FROM "leads" WHERE "id" = $1`,
|
|
463
|
+
[leadIds.get(l.key)],
|
|
464
|
+
);
|
|
465
|
+
const expected = fixture.expected.unstamped.includes(l.key) ? null : personOf(l.workspace, l.email);
|
|
466
|
+
expect({ key: l.key, personId: rows[0]!.person_id }).toEqual({ key: l.key, personId: expected });
|
|
467
|
+
if (expected === null) expect(fixture.expected.unstamped).toContain(l.key);
|
|
468
|
+
}
|
|
469
|
+
for (const a of fixture.input.appointments) {
|
|
470
|
+
const { rows } = await db.query<{ person_id: string | null }>(
|
|
471
|
+
`SELECT "person_id" FROM "appointments" WHERE "id" = $1`,
|
|
472
|
+
[appointmentIds.get(a.key)],
|
|
473
|
+
);
|
|
474
|
+
const expected = fixture.expected.unstamped.includes(a.key) ? null : personOf(a.workspace, a.attendeeEmail);
|
|
475
|
+
expect({ key: a.key, personId: rows[0]!.person_id }).toEqual({ key: a.key, personId: expected });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("makes no Person from a Subscriber", async () => {
|
|
480
|
+
const { rows } = await db.query(
|
|
481
|
+
`SELECT 1 FROM "people" WHERE "email" = $1`,
|
|
482
|
+
[fixture.input.subscribers[0]!.email],
|
|
483
|
+
);
|
|
484
|
+
expect(rows).toHaveLength(0);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("the post-cutover script changes no row on a second run, then stamps a lead written after the first", async () => {
|
|
488
|
+
const before = await snapshot();
|
|
489
|
+
await db.query(readFileSync(BACKFILL_SCRIPT, "utf8"));
|
|
490
|
+
expect(await snapshot()).toEqual(before);
|
|
491
|
+
|
|
492
|
+
const { rows } = await db.query<{ id: string }>(
|
|
493
|
+
`INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload")
|
|
494
|
+
VALUES ($1, $1, 'p852', ' EVE@x.io', '{}') RETURNING "id"`,
|
|
495
|
+
[WS["A"]],
|
|
496
|
+
);
|
|
497
|
+
await db.query(readFileSync(BACKFILL_SCRIPT, "utf8"));
|
|
498
|
+
const stamped = await db.query<{ person_id: string | null }>(
|
|
499
|
+
`SELECT "person_id" FROM "leads" WHERE "id" = $1`,
|
|
500
|
+
[rows[0]!.id],
|
|
501
|
+
);
|
|
502
|
+
const eve = (await readPeople()).find((p) => p.workspace_id === WS["A"] && p.email === "eve@x.io");
|
|
503
|
+
expect(stamped.rows[0]!.person_id).toBe(eve!.id);
|
|
504
|
+
expect((await readPeople()).length).toBe(fixture.expected.people.length);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describeDb("the timeline's pre-migration email fallback (#852 D7, AC-6, live DB)", () => {
|
|
509
|
+
let db: Client;
|
|
510
|
+
const WS_1 = "wksp_01920000-0852-7000-8000-0000000000d1";
|
|
511
|
+
const WS_2 = "wksp_01920000-0852-7000-8000-0000000000d2";
|
|
512
|
+
const LEAD = "lead_01920000-0852-7000-8000-0000000000d1";
|
|
513
|
+
const T = new Date("2026-06-01T12:00:00.000Z");
|
|
514
|
+
const at = (offsetMs: number) => new Date(T.getTime() + offsetMs).toISOString();
|
|
515
|
+
|
|
516
|
+
async function cleanup(): Promise<void> {
|
|
517
|
+
await db.query(`DELETE FROM "flow_sessions" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
|
|
518
|
+
await db.query(`DELETE FROM "project_listings" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const insertSession = async (workspaceId: string, email: string, createdAt: string, leadId: string | null = null) =>
|
|
522
|
+
(
|
|
523
|
+
await db.query<{ id: string }>(
|
|
524
|
+
`INSERT INTO "flow_sessions" ("site_id", "workspace_id", "flow_id", "flow_slug", "config_snapshot", "email", "lead_id", "created_at", "updated_at")
|
|
525
|
+
VALUES ($1, $1, 'p852-flow', 'p852', '{}', $2, $3, $4, now()) RETURNING "id"`,
|
|
526
|
+
[workspaceId, email, leadId, createdAt],
|
|
527
|
+
)
|
|
528
|
+
).rows[0]!.id;
|
|
529
|
+
|
|
530
|
+
const insertListing = async (workspaceId: string, email: string, createdAt: string, leadId: string | null = null) =>
|
|
531
|
+
(
|
|
532
|
+
await db.query<{ id: string }>(
|
|
533
|
+
`INSERT INTO "project_listings" ("site_id", "workspace_id", "founder_name", "founder_email", "status", "lead_id", "created_at", "updated_at")
|
|
534
|
+
VALUES ($1, $1, 'Founder', $2, 'pending', $3, $4, now()) RETURNING "id"`,
|
|
535
|
+
[workspaceId, email, leadId, createdAt],
|
|
536
|
+
)
|
|
537
|
+
).rows[0]!.id;
|
|
538
|
+
|
|
539
|
+
const fallback = async (sql: string, email: string, workspaceId: string) =>
|
|
540
|
+
(await db.query<{ id: string }>(sql, [email, workspaceId, T])).rows.map((r) => r.id);
|
|
541
|
+
|
|
542
|
+
beforeAll(async () => {
|
|
543
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
544
|
+
await db.connect();
|
|
545
|
+
await cleanup();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
afterAll(async () => {
|
|
549
|
+
await cleanup();
|
|
550
|
+
await db.end();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it.each([
|
|
554
|
+
["flow_sessions", PERSON_FALLBACK_FLOW_SESSIONS_SQL, insertSession],
|
|
555
|
+
["project_listings", PERSON_FALLBACK_PROJECT_LISTINGS_SQL, insertListing],
|
|
556
|
+
] as const)("%s: only a lead-less, pre-cutoff row of this workspace with the exact normalized address", async (_t, sql, insert) => {
|
|
557
|
+
const person = `ada-${_t}@x.io`;
|
|
558
|
+
const before = await insert(WS_1, ` ADA-${_t.toUpperCase()}@X.IO\t`, at(-1000));
|
|
559
|
+
await insert(WS_1, person, at(0)); // exactly T
|
|
560
|
+
await insert(WS_1, person, at(1000)); // after T
|
|
561
|
+
await insert(WS_1, person, at(-2000), LEAD); // has a lead
|
|
562
|
+
await insert(WS_2, person, at(-1000)); // another workspace
|
|
563
|
+
expect(await fallback(sql, person, WS_1)).toEqual([before]);
|
|
564
|
+
|
|
565
|
+
// No wildcard: `_` is a literal character.
|
|
566
|
+
await insert(WS_1, `john-${_t}@x.io`, at(-1000));
|
|
567
|
+
expect(await fallback(sql, `j_hn-${_t}@x.io`, WS_1)).toEqual([]);
|
|
568
|
+
|
|
569
|
+
// No non-ASCII case fold: a KELVIN SIGN (U+212A) is not `k`.
|
|
570
|
+
await insert(WS_1, `Kate-${_t}@x.io`, at(-1000));
|
|
571
|
+
expect(await fallback(sql, `kate-${_t}@x.io`, WS_1)).toEqual([]);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("PERSON_CUTOFF_SQL returns the backfill migration's finished_at", async () => {
|
|
575
|
+
const { rows } = await db.query<{ finished_at: Date }>(PERSON_CUTOFF_SQL);
|
|
576
|
+
const direct = await db.query<{ finished_at: Date }>(
|
|
577
|
+
`SELECT "finished_at" FROM "_prisma_migrations" WHERE "migration_name" = $1`,
|
|
578
|
+
[PERSON_BACKFILL_MIGRATION],
|
|
579
|
+
);
|
|
580
|
+
expect(rows).toHaveLength(1);
|
|
581
|
+
expect(rows[0]!.finished_at).toBeInstanceOf(Date);
|
|
582
|
+
expect(rows[0]!.finished_at.getTime()).toBe(direct.rows[0]!.finished_at.getTime());
|
|
583
|
+
});
|
|
584
|
+
});
|
|
@@ -106,6 +106,9 @@ model Workspace {
|
|
|
106
106
|
spokes Spoke[]
|
|
107
107
|
// #3591 — the agents bound to this workspace.
|
|
108
108
|
agents Agent[]
|
|
109
|
+
// #7803 — the people who last activated this workspace (User.lastWorkspaceId).
|
|
110
|
+
// Read and written only by apps/api/src/services/tenant-context.ts.
|
|
111
|
+
lastActiveUsers User[] @relation("UserLastWorkspace")
|
|
109
112
|
|
|
110
113
|
@@unique([organizationId, slug])
|
|
111
114
|
@@index([organizationId])
|
|
@@ -167,6 +170,17 @@ enum AgentPermissionLevel {
|
|
|
167
170
|
/// deleting the creator does not delete the agents they made for others.
|
|
168
171
|
/// `apps/api/src/lib/agent-store.ts` is the only file that names
|
|
169
172
|
/// `prisma.agent`.
|
|
173
|
+
///
|
|
174
|
+
/// `grants` (#7864, spec docs/specs/7864-agent-key-grants-spec.md D2, D3)
|
|
175
|
+
/// lists the permissions an owner or admin granted this agent one by one —
|
|
176
|
+
/// only the seven `@working-theory/validation` names in
|
|
177
|
+
/// `AGENT_GRANTABLE_PERMISSIONS` (content-edit, content-publish, assets-write,
|
|
178
|
+
/// magnets-manage, meeting-types-manage, people-read since #852,
|
|
179
|
+
/// member-pieces-manage since #7878), held to that set by the
|
|
180
|
+
/// `agents_grants_allowed` CHECK. A grant never raises the ceiling above. The
|
|
181
|
+
/// agent holds a stored grant only while its person's own role holds it too:
|
|
182
|
+
/// the tenant guards clamp it on every request, so a demoted person's agent
|
|
183
|
+
/// keeps the row but loses the permission.
|
|
170
184
|
model Agent {
|
|
171
185
|
id String @id @default(dbgenerated("('agent_'::text || (uuidv7())::text)")) @db.Text
|
|
172
186
|
organizationId String @map("organization_id")
|
|
@@ -174,6 +188,7 @@ model Agent {
|
|
|
174
188
|
responsibleMembershipId String @map("responsible_membership_id")
|
|
175
189
|
name String @db.VarChar(80)
|
|
176
190
|
permissionLevel AgentPermissionLevel @default(member) @map("permission_level")
|
|
191
|
+
grants String[] @default([]) @map("grants")
|
|
177
192
|
enabled Boolean @default(true)
|
|
178
193
|
credentialHash String @unique @map("credential_hash") @db.Char(64)
|
|
179
194
|
credentialPrefix String @map("credential_prefix") @db.VarChar(24)
|
|
@@ -374,7 +389,16 @@ model User {
|
|
|
374
389
|
memberships Membership[]
|
|
375
390
|
sentInvitations Invitation[] @relation("sent_invitations")
|
|
376
391
|
|
|
392
|
+
// #7803 — the workspace this person last activated, so a new sign-in lands
|
|
393
|
+
// there. Written only by activateWorkspace after its membership grant; read
|
|
394
|
+
// only by listTenancy, which reports it only when the workspace is in the
|
|
395
|
+
// caller's own listing (apps/api/src/services/tenant-context.ts). Deleting
|
|
396
|
+
// the workspace clears it. Never a Better Auth field, so no client can set it.
|
|
397
|
+
lastWorkspaceId String? @map("last_workspace_id")
|
|
398
|
+
lastWorkspace Workspace? @relation("UserLastWorkspace", fields: [lastWorkspaceId], references: [id], onDelete: SetNull)
|
|
399
|
+
|
|
377
400
|
@@unique([billingProvider, billingCustomerId])
|
|
401
|
+
@@index([lastWorkspaceId])
|
|
378
402
|
@@map("users")
|
|
379
403
|
}
|
|
380
404
|
|
|
@@ -136,6 +136,19 @@ model WorkspaceMeetings {
|
|
|
136
136
|
@@map("workspace_meetings")
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/// WorkspacePieceStages — a workspace's declared piece stages, in order
|
|
140
|
+
/// (#7877). One row per workspace, the WorkspaceMeetings shape; no row means
|
|
141
|
+
/// the product default (apps/api, tenant-db.ts DEFAULT_PIECE_STAGES).
|
|
142
|
+
model WorkspacePieceStages {
|
|
143
|
+
id String @id @default(dbgenerated("('pstages_'::text || (uuidv7())::text)")) @db.Text
|
|
144
|
+
workspaceId String @unique @map("workspace_id")
|
|
145
|
+
stages String[]
|
|
146
|
+
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
|
147
|
+
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
|
148
|
+
|
|
149
|
+
@@map("workspace_piece_stages")
|
|
150
|
+
}
|
|
151
|
+
|
|
139
152
|
/// SchedulingConnection — a workspace's own scheduling provider account
|
|
140
153
|
/// (#7533 D1): the provider id, its credential and the per-connection webhook
|
|
141
154
|
/// entry point + secret, so each workspace reads and receives bookings through
|