Skip to content

Commit 43b82a8

Browse files
authored
ENG-1702 Invite new members in the group (#1027)
* eng-1702-invite-new-member * Cucumber test for invitations, generated by Claude * correction to web * Forgot to add secret_token to supabase/config * linting * prettier * group invitation test * check token type; filename typo * add the database tag to the test * rename test file * Review corrections
1 parent 2b08704 commit 43b82a8

8 files changed

Lines changed: 347 additions & 17 deletions

File tree

apps/website/app/utils/supabase/account.ts

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import type { DGSupabaseClient } from "@repo/database/lib/client";
33

44
type AgentType = Database["public"]["Enums"]["AgentType"] | "group";
55

6-
export const getSessionUserData = async (
6+
export const getSessionBaseUserData = async (
77
client: DGSupabaseClient,
88
): Promise<{
99
id: string;
10-
name: string;
10+
name?: string;
1111
type: AgentType;
1212
email?: string;
1313
} | null> => {
@@ -36,16 +36,79 @@ export const getSessionUserData = async (
3636
return { name, id, email, type: "group" };
3737
}
3838
}
39-
const accountReq = await client
40-
.from("PlatformAccount")
41-
.select("name")
42-
.eq("dg_account", id)
43-
.eq("agent_type", "person")
44-
.maybeSingle();
45-
if (accountReq.error || !accountReq.data) {
46-
return null;
39+
return { id, type: "person", email };
40+
};
41+
42+
export const getSessionUserData = async (
43+
client: DGSupabaseClient,
44+
): Promise<{
45+
id: string;
46+
name: string;
47+
type: AgentType;
48+
email?: string;
49+
} | null> => {
50+
const data = await getSessionBaseUserData(client);
51+
if (data === null) return null;
52+
if (!data.name && data.type === "person") {
53+
const accountReq = await client
54+
.from("PlatformAccount")
55+
.select("name")
56+
.eq("dg_account", data.id)
57+
.eq("agent_type", "person")
58+
.maybeSingle();
59+
if (accountReq.error || !accountReq.data?.name) {
60+
return null;
61+
}
62+
return { ...data, name: accountReq.data.name };
4763
}
48-
return { id, name: accountReq.data.name, type: "person", email };
64+
const { name } = data;
65+
if (name === undefined) return null;
66+
if (data.name === undefined) return null;
67+
return { ...data, name };
68+
};
69+
70+
export const createGroupInvitation = async ({
71+
client,
72+
groupId,
73+
admin = false,
74+
}: {
75+
client: DGSupabaseClient;
76+
groupId: string;
77+
admin?: boolean;
78+
}): Promise<string | null> => {
79+
const userData = await getSessionBaseUserData(client);
80+
if (!userData) return null;
81+
const membershipReq = await client
82+
.from("group_membership")
83+
.select("admin")
84+
.eq("group_id", groupId)
85+
.eq("member_id", userData.id)
86+
.maybeSingle();
87+
if (membershipReq.data?.admin !== true) return null;
88+
/* eslint-disable @typescript-eslint/naming-convention */
89+
const { data, error } = await client.rpc("create_secret_token", {
90+
/* eslint-disable @typescript-eslint/naming-convention */
91+
v_payload: { groupId, type: "groupInvitation", admin },
92+
expiry_interval: "60d",
93+
/* eslint-enable @typescript-eslint/naming-convention */
94+
});
95+
/* eslint-enable @typescript-eslint/naming-convention */
96+
if (error || !data) return null;
97+
return data;
98+
};
99+
100+
export const acceptGroupInvitation = async (
101+
client: DGSupabaseClient,
102+
token: string,
103+
): Promise<string | null> => {
104+
const userData = await getSessionBaseUserData(client);
105+
if (!userData) return "Not logged in";
106+
const { data, error } = await client.rpc("accept_group_invitation", {
107+
token,
108+
});
109+
if (error) return error.message || "Unknown error";
110+
if (!data) return "Unable to accept invitation";
111+
return null;
49112
};
50113

51114
export const createGroup = async (
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
2+
import { createClient } from "@supabase/supabase-js";
3+
import type { Database } from "@repo/database/dbTypes";
4+
import type { DGSupabaseClient } from "@repo/database/lib/client";
5+
import {
6+
fetchOrCreateSpaceDirect,
7+
fetchOrCreatePlatformAccount,
8+
spaceAnonUserEmail,
9+
} from "@repo/database/lib/contextFunctions";
10+
import {
11+
createGroup,
12+
createGroupInvitation,
13+
acceptGroupInvitation,
14+
} from "../../app/utils/supabase/account";
15+
16+
const SUPABASE_URL = process.env.SUPABASE_URL!;
17+
const ANON_KEY = process.env.SUPABASE_PUBLISHABLE_KEY!;
18+
const SERVICE_KEY = process.env.SUPABASE_SECRET_KEY!;
19+
const PASSWORD = "abcdefgh";
20+
21+
const freshClient = (): DGSupabaseClient =>
22+
createClient<Database, "public">(SUPABASE_URL, ANON_KEY);
23+
24+
const serviceClient = () =>
25+
createClient<Database, "public">(SUPABASE_URL, SERVICE_KEY);
26+
27+
const signedInClient = async (spaceId: number): Promise<DGSupabaseClient> => {
28+
const client = freshClient();
29+
const { error } = await client.auth.signInWithPassword({
30+
email: spaceAnonUserEmail("Roam", spaceId),
31+
password: PASSWORD,
32+
});
33+
if (error) throw new Error(`Sign-in failed: ${error.message}`);
34+
return client;
35+
};
36+
37+
describe(
38+
"group invitation flow (website functions)",
39+
{ tags: ["database"] },
40+
() => {
41+
let spaceId1: number;
42+
let spaceId2: number;
43+
let client1: DGSupabaseClient;
44+
let client2: DGSupabaseClient;
45+
let createdGroupId: string | null = null;
46+
47+
beforeAll(async () => {
48+
const s1 = await fetchOrCreateSpaceDirect({
49+
name: "vitest-s1",
50+
url: "https://roamresearch.com/#/app/vitest-s1",
51+
platform: "Roam",
52+
password: PASSWORD,
53+
});
54+
if (!s1.data)
55+
throw new Error(`Failed to create space 1: ${s1.error?.message}`);
56+
spaceId1 = s1.data.id;
57+
await fetchOrCreatePlatformAccount({
58+
platform: "Roam",
59+
accountLocalId: "vitest-user1",
60+
name: "vitest-user1",
61+
email: "vitest-user1@example.com",
62+
spaceId: spaceId1,
63+
password: PASSWORD,
64+
});
65+
66+
const s2 = await fetchOrCreateSpaceDirect({
67+
name: "vitest-s2",
68+
url: "https://roamresearch.com/#/app/vitest-s2",
69+
platform: "Roam",
70+
password: PASSWORD,
71+
});
72+
if (!s2.data)
73+
throw new Error(`Failed to create space 2: ${s2.error?.message}`);
74+
spaceId2 = s2.data.id;
75+
await fetchOrCreatePlatformAccount({
76+
platform: "Roam",
77+
accountLocalId: "vitest-user2",
78+
name: "vitest-user2",
79+
email: "vitest-user2@example.com",
80+
spaceId: spaceId2,
81+
password: PASSWORD,
82+
});
83+
84+
client1 = await signedInClient(spaceId1);
85+
client2 = await signedInClient(spaceId2);
86+
});
87+
88+
afterAll(async () => {
89+
if (createdGroupId) {
90+
await serviceClient().auth.admin.deleteUser(createdGroupId);
91+
}
92+
});
93+
94+
it("executes the full invitation flow", async () => {
95+
// Step 1: user1 creates a group
96+
const groupId = await createGroup(client1, "vitest-invite-group");
97+
expect(groupId, "createGroup should return a group ID").toBeTruthy();
98+
createdGroupId = groupId;
99+
100+
// Step 2: user1 creates an invitation token
101+
const token = await createGroupInvitation({
102+
client: client1,
103+
groupId: groupId!,
104+
admin: false,
105+
});
106+
expect(token, "createGroupInvitation should return a token").toBeTruthy();
107+
108+
// Step 3: user2 accepts the invitation
109+
const error = await acceptGroupInvitation(client2, token!);
110+
expect(
111+
error,
112+
"acceptGroupInvitation should return null on success",
113+
).toBeNull();
114+
115+
// Step 4: verify user2 is in group_membership
116+
const { data: user2 } = await client2.auth.getUser();
117+
const { data: membership } = await serviceClient()
118+
.from("group_membership")
119+
.select("admin")
120+
.eq("group_id", groupId!)
121+
.eq("member_id", user2.user!.id)
122+
.maybeSingle();
123+
expect(
124+
membership,
125+
"user2 should appear in group_membership",
126+
).toBeTruthy();
127+
expect(membership?.admin).toBe(false);
128+
});
129+
},
130+
);

packages/database/features/groupAccess.feature

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,21 @@ Feature: Group content access
1414
And the user user1 opens the Roam plugin in space s1
1515
And the user user2 opens the Roam plugin in space s2
1616

17+
Scenario: Invitation-based group membership
18+
When user of space s1 creates group invite_group
19+
And user of space s1 creates an invitation for group invite_group
20+
And user of space s2 accepts the group invitation
21+
Then user of space s2 should be a member of group invite_group
22+
1723
Scenario: Creating content
1824
When Document are added to the database:
1925
| $id | source_local_id | created | last_modified | _author_id | _space_id |
20-
| d1 | ld1 | 2025/01/01 | 2025/01/01 | user1 | s1 |
21-
| d2 | ld2 | 2025/01/01 | 2025/01/01 | user1 | s1 |
26+
| d1 | ld1 | 2025/01/01 | 2025/01/01 | user1 | s1 |
27+
| d2 | ld2 | 2025/01/01 | 2025/01/01 | user1 | s1 |
2228
And Content are added to the database:
2329
| $id | source_local_id | _document_id | text | created | last_modified | scale | _author_id | _space_id |
24-
| ct1 | lct1 | d1 | Claim 1 | 2025/01/01 | 2025/01/01 | document | user1 | s1 |
25-
| ct2 | lct2 | d2 | Claim 2 | 2025/01/01 | 2025/01/01 | document | user1 | s1 |
30+
| ct1 | lct1 | d1 | Claim 1 | 2025/01/01 | 2025/01/01 | document | user1 | s1 |
31+
| ct2 | lct2 | d2 | Claim 2 | 2025/01/01 | 2025/01/01 | document | user1 | s1 |
2632
Then a user logged in space s1 should see 2 PlatformAccount in the database
2733
And a user logged in space s1 should see 2 Content in the database
2834
And a user logged in space s2 should see 2 PlatformAccount in the database

packages/database/features/step-definitions/stepdefs.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Given("the database is blank", async () => {
6767
assert.equal(r.error, null);
6868
const r3 = await client.from("group_membership").select("group_id");
6969
assert.equal(r3.error, null);
70+
// eslint-disable-next-line @typescript-eslint/naming-convention
7071
const groupIds = new Set((r3.data || []).map(({ group_id }) => group_id));
7172
for (const id of groupIds) {
7273
const ur = await client.auth.admin.deleteUser(id);
@@ -77,8 +78,9 @@ Given("the database is blank", async () => {
7778
.select("dg_account")
7879
.not("dg_account", "is", null);
7980
assert.equal(r2.error, null);
81+
// eslint-disable-next-line @typescript-eslint/naming-convention
8082
for (const { dg_account } of r2.data || []) {
81-
const r = await client.auth.admin.deleteUser(dg_account!);
83+
const r = await client.auth.admin.deleteUser(dg_account);
8284
assert.equal(r.error, null);
8385
}
8486
r = await client.from("PlatformAccount").delete().neq("id", -1);
@@ -433,6 +435,78 @@ When(
433435
},
434436
);
435437

438+
When(
439+
"user of space {word} creates an invitation for group {word}",
440+
async (spaceName: string, groupName: string): Promise<void> => {
441+
const localRefs = (world.localRefs || {}) as LocalRefsType;
442+
const spaceId = localRefs[spaceName];
443+
const groupId = localRefs[groupName];
444+
if (typeof spaceId !== "number") assert.fail("spaceId not a number");
445+
if (typeof groupId !== "string") assert.fail("groupId not a string");
446+
const client = await getLoggedinDatabase(spaceId);
447+
/* eslint-disable @typescript-eslint/naming-convention */
448+
const { data, error } = await client.rpc("create_secret_token", {
449+
v_payload: { groupId, type: "groupInvitation", admin: false },
450+
expiry_interval: "60d",
451+
});
452+
/* eslint-enable @typescript-eslint/naming-convention */
453+
assert.equal(error, null);
454+
assert.ok(data, "create_secret_token returned no token");
455+
world.lastInvitationToken = data;
456+
},
457+
);
458+
459+
When(
460+
"user of space {word} accepts the group invitation",
461+
async (spaceName: string): Promise<void> => {
462+
const localRefs = (world.localRefs || {}) as LocalRefsType;
463+
const spaceId = localRefs[spaceName];
464+
if (typeof spaceId !== "number") assert.fail("spaceId not a number");
465+
const token = world.lastInvitationToken as string;
466+
assert.ok(
467+
token,
468+
"No invitation token stored — run 'creates an invitation' first",
469+
);
470+
const client = await getLoggedinDatabase(spaceId);
471+
const { data, error } = await client.rpc("accept_group_invitation", {
472+
token,
473+
});
474+
assert.equal(error, null);
475+
assert.strictEqual(data, true, "accept_group_invitation returned false");
476+
},
477+
);
478+
479+
Then(
480+
"user of space {word} should be a member of group {word}",
481+
async (spaceName: string, groupName: string): Promise<void> => {
482+
const localRefs = (world.localRefs || {}) as LocalRefsType;
483+
const spaceId = localRefs[spaceName];
484+
const groupId = localRefs[groupName];
485+
if (typeof spaceId !== "number") assert.fail("spaceId not a number");
486+
if (typeof groupId !== "string") assert.fail("groupId not a string");
487+
const serviceClient = getServiceClient();
488+
const r1 = await serviceClient
489+
.from("PlatformAccount")
490+
.select("dg_account")
491+
.eq("account_local_id", spaceAnonUserEmail("Roam", spaceId))
492+
.maybeSingle();
493+
assert.equal(r1.error, null);
494+
const memberId = r1.data?.dg_account;
495+
assert.ok(memberId, "dg_account not found for space");
496+
const r2 = await serviceClient
497+
.from("group_membership")
498+
.select("member_id")
499+
.eq("group_id", groupId)
500+
.eq("member_id", memberId)
501+
.maybeSingle();
502+
assert.equal(r2.error, null);
503+
assert.ok(
504+
r2.data,
505+
`user of space ${spaceName} is not a member of group ${groupName}`,
506+
);
507+
},
508+
);
509+
436510
When(
437511
"user of space {word} adds space {word} to group {word}",
438512
async (

packages/database/src/dbTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,6 +1454,7 @@ export type Database = {
14541454
isSetofReturn: false
14551455
}
14561456
}
1457+
accept_group_invitation: { Args: { token: string }; Returns: boolean }
14571458
account_in_shared_space: {
14581459
Args: {
14591460
access_level?: Database["public"]["Enums"]["SpaceAccessPermissions"]

packages/database/supabase/config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ schema_paths = [
5252
'./schemas/base.sql',
5353
'./schemas/extensions.sql',
5454
'./schemas/space.sql',
55+
'./schemas/secret_tokens.sql',
5556
'./schemas/account.sql',
5657
'./schemas/content.sql',
5758
'./schemas/embedding.sql',

0 commit comments

Comments
 (0)