Skip to content

Commit 4ef3cca

Browse files
committed
refactor: accept an invitation in one request
1 parent 118fc09 commit 4ef3cca

15 files changed

Lines changed: 300 additions & 286 deletions

File tree

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2-
import {
3-
completedInviteOnboardingSchema,
4-
completeInviteOnboardingSchema,
5-
completeOnboardingSchema,
6-
} from "../../onboarding.ts";
2+
import { completeOnboardingSchema } from "../../onboarding.ts";
73
import { refused } from "../errors.ts";
84
import { Session } from "../middleware.ts";
95

10-
/** The signed-in person finishing setting up a workspace, or joining one by invitation. */
6+
/** The signed-in person finishing setting up a workspace. */
117
export class OnboardingApi extends HttpApiGroup.make("onboarding")
128
.add(
139
HttpApiEndpoint.post("complete", "/onboarding/complete", {
1410
payload: completeOnboardingSchema,
1511
error: refused,
1612
}),
17-
HttpApiEndpoint.post("completeInvite", "/onboarding/complete-invite", {
18-
payload: completeInviteOnboardingSchema,
19-
success: completedInviteOnboardingSchema,
20-
}),
2113
)
2214
.middleware(Session) {}

‎packages/contracts/src/membership.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,18 @@ export const newWorkspaceInvitationSchema = Schema.Struct({
7070
});
7171
export type NewWorkspaceInvitation = typeof newWorkspaceInvitationSchema.Type;
7272

73-
export const invitationPreviewSchema = Schema.Struct({
74-
workspaceName: Schema.String,
75-
inviterName: Schema.String,
76-
});
73+
/**
74+
* An invitation as the person it was sent to sees it: one still to accept, or
75+
* one they have accepted already, whose link leads back into its workspace.
76+
*/
77+
export const invitationPreviewSchema = Schema.Union([
78+
Schema.Struct({
79+
status: Schema.Literal("pending"),
80+
workspaceName: Schema.String,
81+
inviterName: Schema.String,
82+
}),
83+
Schema.Struct({ status: Schema.Literal("accepted"), workspaceId: uuidSchema }),
84+
]);
7785
export type InvitationPreview = typeof invitationPreviewSchema.Type;
7886

7987
export const acceptedInvitationSchema = Schema.Struct({ workspaceId: uuidSchema });

‎packages/contracts/src/onboarding.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,3 @@ export const completeOnboardingSchema = Schema.Struct({
77
agentId: uuidSchema,
88
});
99
export type CompleteOnboarding = typeof completeOnboardingSchema.Type;
10-
11-
export const completeInviteOnboardingSchema = Schema.Struct({ invitationId: uuidSchema });
12-
export type CompleteInviteOnboarding = typeof completeInviteOnboardingSchema.Type;
13-
14-
export const completedInviteOnboardingSchema = Schema.Struct({ workspaceId: uuidSchema });
15-
export type CompletedInviteOnboarding = typeof completedInviteOnboardingSchema.Type;

‎packages/core/src/workspaces/membership/membership.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import { Accounts } from "../../accounts/accounts.ts";
55
import { CurrentActor } from "../../authorization/current-actor.ts";
66
import {
77
agent,
8+
modelProvider,
89
pod,
910
podMember,
11+
providerModel,
1012
searchProvider,
1113
user,
14+
workspaceDefaultModel,
1215
workspaceInvite,
1316
workspaceMember,
1417
} from "../../database/schema.ts";
@@ -243,6 +246,7 @@ describe.skipIf(!process.env.DATABASE_URL)("Membership, against Postgres", () =>
243246
});
244247
expect(sent.at(-1)?.text).toContain(`${WEB_APP_URL}/invite/${invitation.id}`);
245248
expect(await run(bob.id, (m) => m.invitation({ invitationId: invitation.id }))).toEqual({
249+
status: "pending",
246250
workspaceName: "Nitric",
247251
inviterName: "Ada",
248252
});
@@ -260,6 +264,91 @@ describe.skipIf(!process.env.DATABASE_URL)("Membership, against Postgres", () =>
260264
).toHaveLength(1);
261265
});
262266

267+
it("leads an invitee back in from a link already accepted, until they leave", async () => {
268+
const { ada, workspace } = await workspaceOfAda();
269+
const bob = await person("Bob");
270+
const invitation = await run(ada.id, (m) =>
271+
m.invite({ workspace: workspace.id, invitation: { email: bob.email, role: "member" } }),
272+
);
273+
if ("failed" in invitation) throw new Error(invitation.failed);
274+
await run(bob.id, (m) => m.accept({ invitationId: invitation.id }));
275+
276+
expect(await run(bob.id, (m) => m.invitation({ invitationId: invitation.id }))).toEqual({
277+
status: "accepted",
278+
workspaceId: workspace.id,
279+
});
280+
expect(await run(bob.id, (m) => m.accept({ invitationId: invitation.id }))).toEqual({
281+
workspaceId: workspace.id,
282+
});
283+
expect(await run(bob.id, (m) => m.members({ workspace: workspace.id }))).toHaveLength(2);
284+
285+
await run(bob.id, (m) => m.leave({ workspace: workspace.id }));
286+
287+
expect(await run(bob.id, (m) => m.invitation({ invitationId: invitation.id }))).toEqual({
288+
failed: "ResourceHidden",
289+
});
290+
expect(await run(bob.id, (m) => m.accept({ invitationId: invitation.id }))).toEqual({
291+
failed: "ResourceHidden",
292+
});
293+
});
294+
295+
it("starts somebody joining on another model the workspace offers while it does not offer its default", async () => {
296+
const { ada, workspace } = await workspaceOfAda();
297+
const bob = await person("Bob");
298+
const [provider] = await onDatabase((db) =>
299+
db
300+
.insert(modelProvider)
301+
.values({
302+
workspaceId: workspace.id,
303+
name: "Models",
304+
baseUrl: "https://models.example/v1",
305+
apiFormat: "openai",
306+
active: true,
307+
})
308+
.returning(),
309+
);
310+
if (!provider) throw new Error("could not create the model provider");
311+
await onDatabase((db) =>
312+
db.insert(providerModel).values(
313+
[
314+
{ modelId: "offered", enabled: true },
315+
{ modelId: "switched-off", enabled: false },
316+
].map((model) => ({
317+
...model,
318+
workspaceId: workspace.id,
319+
providerId: provider.id,
320+
source: "manual" as const,
321+
})),
322+
),
323+
);
324+
await onDatabase((db) =>
325+
db
326+
.insert(workspaceDefaultModel)
327+
.values({ workspaceId: workspace.id, modelId: "switched-off" }),
328+
);
329+
const invitation = await run(ada.id, (m) =>
330+
m.invite({ workspace: workspace.id, invitation: { email: bob.email, role: "member" } }),
331+
);
332+
if ("failed" in invitation) throw new Error(invitation.failed);
333+
334+
await run(bob.id, (m) => m.accept({ invitationId: invitation.id }));
335+
336+
const [assistant] = await onDatabase((db) =>
337+
db
338+
.select({ model: agent.model })
339+
.from(agent)
340+
.innerJoin(pod, eq(pod.id, agent.podId))
341+
.where(
342+
and(
343+
eq(pod.workspaceId, workspace.id),
344+
eq(pod.ownerId, bob.id),
345+
eq(agent.provisionedKey, "personal-assistant"),
346+
),
347+
),
348+
);
349+
expect(assistant?.model).toBe("offered");
350+
});
351+
263352
it("refuses an invitation to anybody but the address it was sent to", async () => {
264353
const { ada, workspace } = await workspaceOfAda();
265354
const bob = await person("Bob");

‎packages/core/src/workspaces/membership/membership.ts‎

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
WorkspacePermissions,
1313
WorkspaceRole,
1414
} from "@sugabots/contracts";
15-
import { and, asc, eq, gt, sql } from "drizzle-orm";
15+
import { and, asc, eq, gt, or, sql } from "drizzle-orm";
1616
import { Context, Data, DateTime, Duration, Effect, Layer } from "effect";
1717
import { Accounts } from "../../accounts/accounts.ts";
1818
import {
@@ -40,6 +40,7 @@ import { user, workspace, workspaceInvite, workspaceMember } from "../../databas
4040
import { Email } from "../../email/email.ts";
4141
import { Ids, isUuid } from "../../ids/ids.ts";
4242
import { Installation } from "../../installation/installation.ts";
43+
import { offeredModels } from "../../providers/model-providers/model-provider-reads.ts";
4344
import { ModelProviderRepository } from "../../providers/model-providers/model-provider-repository.ts";
4445
import { SearchProviderRepository } from "../../providers/search-providers/search-provider-repository.ts";
4546
import { type UserFacing, UserMessage } from "../../user-message.ts";
@@ -123,10 +124,18 @@ export interface Interface {
123124
readonly cancelInvitation: (
124125
input: ForInvitation,
125126
) => Effect.Effect<void, AuthorizationDenied, CurrentActor.Service>;
126-
/** For the person the invitation was sent to. */
127+
/**
128+
* For the person the invitation was sent to: one still to accept, or one
129+
* they have accepted and whose workspace they are still in.
130+
*/
127131
readonly invitation: (
128132
input: ForInvitation,
129133
) => Effect.Effect<InvitationPreview, ResourceHidden | NotTheInvitee, CurrentActor.Service>;
134+
/**
135+
* Lets the invitee in, with their Personal pod. Accepting again, as a
136+
* second tab or a reload does, answers with the workspace and changes
137+
* nothing.
138+
*/
130139
readonly accept: (
131140
input: ForInvitation,
132141
) => Effect.Effect<
@@ -160,6 +169,25 @@ export const make = Effect.gen(function* () {
160169
const modelProviders = yield* ModelProviderRepository.Service;
161170
const ids = yield* Ids.Service;
162171

172+
/**
173+
* The Personal pod of somebody joining, its assistant on the workspace's
174+
* default model, or on another the workspace offers while it does not offer
175+
* that one.
176+
*/
177+
const provisionJoining = (workspaceId: string, userId: string) =>
178+
Effect.gen(function* () {
179+
const { models, defaultModel } = yield* offeredModels(workspaceId);
180+
// The default goes unoffered while a failed test has its provider off.
181+
const model = models.some((offered) => offered.modelId === defaultModel)
182+
? defaultModel
183+
: models[0]?.modelId;
184+
if (!model) return yield* personalPods.provision({ workspaceId, userId });
185+
return yield* personalPods.provisionWithModel({ workspaceId, userId, model }).pipe(
186+
// Switched off since it was read: start on none, as while the workspace offers none.
187+
Effect.catchTag("ModelNotEnabled", () => personalPods.provision({ workspaceId, userId })),
188+
);
189+
});
190+
163191
const sendInvitation = (
164192
invitationId: string,
165193
to: string,
@@ -556,7 +584,12 @@ export const make = Effect.gen(function* () {
556584
Effect.gen(function* () {
557585
const { userId } = yield* CurrentActor.Service;
558586
const invitation = yield* invitationFor(userId, input.invitationId);
559-
return { workspaceName: invitation.workspaceName, inviterName: invitation.inviterName };
587+
if (invitation.status === "accepted") return invitation;
588+
return {
589+
status: "pending" as const,
590+
workspaceName: invitation.workspaceName,
591+
inviterName: invitation.inviterName,
592+
};
560593
}),
561594
),
562595

@@ -567,6 +600,7 @@ export const make = Effect.gen(function* () {
567600
Effect.gen(function* () {
568601
const { userId } = yield* CurrentActor.Service;
569602
const invitation = yield* invitationFor(userId, input.invitationId);
603+
if (invitation.status === "accepted") return { workspaceId: invitation.workspaceId };
570604
if (accounts.requireEmailVerification && !invitation.inviteeVerified) {
571605
return yield* new EmailUnverified();
572606
}
@@ -586,7 +620,7 @@ export const make = Effect.gen(function* () {
586620
.where(eq(workspaceInvite.id, input.invitationId));
587621
}),
588622
);
589-
yield* personalPods.provision({ workspaceId: invitation.workspaceId, userId });
623+
yield* provisionJoining(invitation.workspaceId, userId);
590624
return { workspaceId: invitation.workspaceId };
591625
}),
592626
),
@@ -738,16 +772,20 @@ function deleteMember(memberId: string) {
738772
}
739773

740774
/**
741-
* A pending, unexpired invitation, locked for an enclosing transaction, when
742-
* `userId` is the person it was sent to. Anything else is hidden, so a link
743-
* cannot be probed.
775+
* The invitation `invitationId`, locked for an enclosing transaction, when
776+
* `userId` is the person it was sent to: pending and unexpired, or accepted
777+
* while they are still in its workspace, so its link leads back in. Anything
778+
* else is hidden, so a link cannot be probed, and leaving a workspace ends
779+
* what its link can do.
744780
*/
745781
function invitationFor(userId: string, invitationId: string) {
746782
return Effect.gen(function* () {
747783
const now = yield* DateTime.nowAsDate;
748784
const [invitation] = yield* query((db) =>
749785
db
750786
.select({
787+
status: workspaceInvite.status,
788+
stillIn: workspaceMember.id,
751789
workspaceId: workspaceInvite.workspaceId,
752790
email: workspaceInvite.email,
753791
role: workspaceInvite.role,
@@ -757,24 +795,45 @@ function invitationFor(userId: string, invitationId: string) {
757795
.from(workspaceInvite)
758796
.innerJoin(workspace, eq(workspace.id, workspaceInvite.workspaceId))
759797
.innerJoin(user, eq(user.id, workspaceInvite.inviterId))
798+
.leftJoin(
799+
workspaceMember,
800+
and(
801+
eq(workspaceMember.workspaceId, workspaceInvite.workspaceId),
802+
eq(workspaceMember.userId, userId),
803+
),
804+
)
760805
.where(
761806
and(
762807
eq(workspaceInvite.id, invitationId),
763-
eq(workspaceInvite.status, "pending"),
764-
gt(workspaceInvite.expiresAt, now),
808+
or(
809+
and(eq(workspaceInvite.status, "pending"), gt(workspaceInvite.expiresAt, now)),
810+
eq(workspaceInvite.status, "accepted"),
811+
),
765812
),
766813
)
767814
.for("update", { of: workspaceInvite }),
768815
);
769-
if (!invitation) return yield* new ResourceHidden({ resource: "invitation" });
816+
const hidden = new ResourceHidden({ resource: "invitation" });
817+
if (!invitation) return yield* hidden;
818+
if (invitation.status === "accepted" && invitation.stillIn === null) return yield* hidden;
770819
const [invitee] = yield* query((db) =>
771820
db
772821
.select({ email: user.email, emailVerified: user.emailVerified })
773822
.from(user)
774823
.where(eq(user.id, userId)),
775824
);
776825
if (invitee?.email.toLowerCase() !== invitation.email) return yield* new NotTheInvitee();
777-
return { ...invitation, inviteeVerified: invitee.emailVerified };
826+
if (invitation.status === "accepted") {
827+
return { status: "accepted" as const, workspaceId: invitation.workspaceId };
828+
}
829+
return {
830+
status: "pending" as const,
831+
workspaceId: invitation.workspaceId,
832+
role: invitation.role,
833+
workspaceName: invitation.workspaceName,
834+
inviterName: invitation.inviterName,
835+
inviteeVerified: invitee.emailVerified,
836+
};
778837
});
779838
}
780839

0 commit comments

Comments
 (0)