Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions packages/contracts/src/http/groups/onboarding.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
import {
completedInviteOnboardingSchema,
completeInviteOnboardingSchema,
completeOnboardingSchema,
} from "../../onboarding.ts";
import { completeOnboardingSchema } from "../../onboarding.ts";
import { refused } from "../errors.ts";
import { Session } from "../middleware.ts";

/** The signed-in person finishing setting up a workspace, or joining one by invitation. */
/** The signed-in person finishing setting up a workspace. */
export class OnboardingApi extends HttpApiGroup.make("onboarding")
.add(
HttpApiEndpoint.post("complete", "/onboarding/complete", {
payload: completeOnboardingSchema,
error: refused,
}),
HttpApiEndpoint.post("completeInvite", "/onboarding/complete-invite", {
payload: completeInviteOnboardingSchema,
success: completedInviteOnboardingSchema,
}),
)
.middleware(Session) {}
16 changes: 12 additions & 4 deletions packages/contracts/src/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,18 @@ export const newWorkspaceInvitationSchema = Schema.Struct({
});
export type NewWorkspaceInvitation = typeof newWorkspaceInvitationSchema.Type;

export const invitationPreviewSchema = Schema.Struct({
workspaceName: Schema.String,
inviterName: Schema.String,
});
/**
* An invitation as the person it was sent to sees it: one still to accept, or
* one they have accepted already, whose link leads back into its workspace.
*/
export const invitationPreviewSchema = Schema.Union([
Schema.Struct({
status: Schema.Literal("pending"),
workspaceName: Schema.String,
inviterName: Schema.String,
}),
Schema.Struct({ status: Schema.Literal("accepted"), workspaceId: uuidSchema }),
]);
export type InvitationPreview = typeof invitationPreviewSchema.Type;

export const acceptedInvitationSchema = Schema.Struct({ workspaceId: uuidSchema });
6 changes: 0 additions & 6 deletions packages/contracts/src/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,3 @@ export const completeOnboardingSchema = Schema.Struct({
agentId: uuidSchema,
});
export type CompleteOnboarding = typeof completeOnboardingSchema.Type;

export const completeInviteOnboardingSchema = Schema.Struct({ invitationId: uuidSchema });
export type CompleteInviteOnboarding = typeof completeInviteOnboardingSchema.Type;

export const completedInviteOnboardingSchema = Schema.Struct({ workspaceId: uuidSchema });
export type CompletedInviteOnboarding = typeof completedInviteOnboardingSchema.Type;
89 changes: 89 additions & 0 deletions packages/core/src/workspaces/membership/membership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import { Accounts } from "../../accounts/accounts.ts";
import { CurrentActor } from "../../authorization/current-actor.ts";
import {
agent,
modelProvider,
pod,
podMember,
providerModel,
searchProvider,
user,
workspaceDefaultModel,
workspaceInvite,
workspaceMember,
} from "../../database/schema.ts";
Expand Down Expand Up @@ -243,6 +246,7 @@ describe.skipIf(!process.env.DATABASE_URL)("Membership, against Postgres", () =>
});
expect(sent.at(-1)?.text).toContain(`${WEB_APP_URL}/invite/${invitation.id}`);
expect(await run(bob.id, (m) => m.invitation({ invitationId: invitation.id }))).toEqual({
status: "pending",
workspaceName: "Nitric",
inviterName: "Ada",
});
Expand All @@ -260,6 +264,91 @@ describe.skipIf(!process.env.DATABASE_URL)("Membership, against Postgres", () =>
).toHaveLength(1);
});

it("leads an invitee back in from a link already accepted, until they leave", async () => {
const { ada, workspace } = await workspaceOfAda();
const bob = await person("Bob");
const invitation = await run(ada.id, (m) =>
m.invite({ workspace: workspace.id, invitation: { email: bob.email, role: "member" } }),
);
if ("failed" in invitation) throw new Error(invitation.failed);
await run(bob.id, (m) => m.accept({ invitationId: invitation.id }));

expect(await run(bob.id, (m) => m.invitation({ invitationId: invitation.id }))).toEqual({
status: "accepted",
workspaceId: workspace.id,
});
expect(await run(bob.id, (m) => m.accept({ invitationId: invitation.id }))).toEqual({
workspaceId: workspace.id,
});
expect(await run(bob.id, (m) => m.members({ workspace: workspace.id }))).toHaveLength(2);

await run(bob.id, (m) => m.leave({ workspace: workspace.id }));

expect(await run(bob.id, (m) => m.invitation({ invitationId: invitation.id }))).toEqual({
failed: "ResourceHidden",
});
expect(await run(bob.id, (m) => m.accept({ invitationId: invitation.id }))).toEqual({
failed: "ResourceHidden",
});
});

it("starts somebody joining on another model the workspace offers while it does not offer its default", async () => {
const { ada, workspace } = await workspaceOfAda();
const bob = await person("Bob");
const [provider] = await onDatabase((db) =>
db
.insert(modelProvider)
.values({
workspaceId: workspace.id,
name: "Models",
baseUrl: "https://models.example/v1",
apiFormat: "openai",
active: true,
})
.returning(),
);
if (!provider) throw new Error("could not create the model provider");
await onDatabase((db) =>
db.insert(providerModel).values(
[
{ modelId: "offered", enabled: true },
{ modelId: "switched-off", enabled: false },
].map((model) => ({
...model,
workspaceId: workspace.id,
providerId: provider.id,
source: "manual" as const,
})),
),
);
await onDatabase((db) =>
db
.insert(workspaceDefaultModel)
.values({ workspaceId: workspace.id, modelId: "switched-off" }),
);
const invitation = await run(ada.id, (m) =>
m.invite({ workspace: workspace.id, invitation: { email: bob.email, role: "member" } }),
);
if ("failed" in invitation) throw new Error(invitation.failed);

await run(bob.id, (m) => m.accept({ invitationId: invitation.id }));

const [assistant] = await onDatabase((db) =>
db
.select({ model: agent.model })
.from(agent)
.innerJoin(pod, eq(pod.id, agent.podId))
.where(
and(
eq(pod.workspaceId, workspace.id),
eq(pod.ownerId, bob.id),
eq(agent.provisionedKey, "personal-assistant"),
),
),
);
expect(assistant?.model).toBe("offered");
});

it("refuses an invitation to anybody but the address it was sent to", async () => {
const { ada, workspace } = await workspaceOfAda();
const bob = await person("Bob");
Expand Down
81 changes: 70 additions & 11 deletions packages/core/src/workspaces/membership/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
WorkspacePermissions,
WorkspaceRole,
} from "@sugabots/contracts";
import { and, asc, eq, gt, sql } from "drizzle-orm";
import { and, asc, eq, gt, or, sql } from "drizzle-orm";
import { Context, Data, DateTime, Duration, Effect, Layer } from "effect";
import { Accounts } from "../../accounts/accounts.ts";
import {
Expand Down Expand Up @@ -40,6 +40,7 @@ import { user, workspace, workspaceInvite, workspaceMember } from "../../databas
import { Email } from "../../email/email.ts";
import { Ids, isUuid } from "../../ids/ids.ts";
import { Installation } from "../../installation/installation.ts";
import { offeredModels } from "../../providers/model-providers/model-provider-reads.ts";
import { ModelProviderRepository } from "../../providers/model-providers/model-provider-repository.ts";
import { SearchProviderRepository } from "../../providers/search-providers/search-provider-repository.ts";
import { type UserFacing, UserMessage } from "../../user-message.ts";
Expand Down Expand Up @@ -123,10 +124,18 @@ export interface Interface {
readonly cancelInvitation: (
input: ForInvitation,
) => Effect.Effect<void, AuthorizationDenied, CurrentActor.Service>;
/** For the person the invitation was sent to. */
/**
* For the person the invitation was sent to: one still to accept, or one
* they have accepted and whose workspace they are still in.
*/
readonly invitation: (
input: ForInvitation,
) => Effect.Effect<InvitationPreview, ResourceHidden | NotTheInvitee, CurrentActor.Service>;
/**
* Lets the invitee in, with their Personal pod. Accepting again, as a
* second tab or a reload does, answers with the workspace and changes
* nothing.
*/
readonly accept: (
input: ForInvitation,
) => Effect.Effect<
Expand Down Expand Up @@ -160,6 +169,25 @@ export const make = Effect.gen(function* () {
const modelProviders = yield* ModelProviderRepository.Service;
const ids = yield* Ids.Service;

/**
* The Personal pod of somebody joining, its assistant on the workspace's
* default model, or on another the workspace offers while it does not offer
* that one.
*/
const provisionJoining = (workspaceId: string, userId: string) =>
Effect.gen(function* () {
const { models, defaultModel } = yield* offeredModels(workspaceId);
// The default goes unoffered while a failed test has its provider off.
const model = models.some((offered) => offered.modelId === defaultModel)
? defaultModel
: models[0]?.modelId;
if (!model) return yield* personalPods.provision({ workspaceId, userId });
return yield* personalPods.provisionWithModel({ workspaceId, userId, model }).pipe(
// Switched off since it was read: start on none, as while the workspace offers none.
Effect.catchTag("ModelNotEnabled", () => personalPods.provision({ workspaceId, userId })),
);
});

const sendInvitation = (
invitationId: string,
to: string,
Expand Down Expand Up @@ -556,7 +584,12 @@ export const make = Effect.gen(function* () {
Effect.gen(function* () {
const { userId } = yield* CurrentActor.Service;
const invitation = yield* invitationFor(userId, input.invitationId);
return { workspaceName: invitation.workspaceName, inviterName: invitation.inviterName };
if (invitation.status === "accepted") return invitation;
return {
status: "pending" as const,
workspaceName: invitation.workspaceName,
inviterName: invitation.inviterName,
};
}),
),

Expand All @@ -567,6 +600,7 @@ export const make = Effect.gen(function* () {
Effect.gen(function* () {
const { userId } = yield* CurrentActor.Service;
const invitation = yield* invitationFor(userId, input.invitationId);
if (invitation.status === "accepted") return { workspaceId: invitation.workspaceId };
if (accounts.requireEmailVerification && !invitation.inviteeVerified) {
return yield* new EmailUnverified();
}
Expand All @@ -586,7 +620,7 @@ export const make = Effect.gen(function* () {
.where(eq(workspaceInvite.id, input.invitationId));
}),
);
yield* personalPods.provision({ workspaceId: invitation.workspaceId, userId });
yield* provisionJoining(invitation.workspaceId, userId);
return { workspaceId: invitation.workspaceId };
}),
),
Expand Down Expand Up @@ -738,16 +772,20 @@ function deleteMember(memberId: string) {
}

/**
* A pending, unexpired invitation, locked for an enclosing transaction, when
* `userId` is the person it was sent to. Anything else is hidden, so a link
* cannot be probed.
* The invitation `invitationId`, locked for an enclosing transaction, when
* `userId` is the person it was sent to: pending and unexpired, or accepted
* while they are still in its workspace, so its link leads back in. Anything
* else is hidden, so a link cannot be probed, and leaving a workspace ends
* what its link can do.
*/
function invitationFor(userId: string, invitationId: string) {
return Effect.gen(function* () {
const now = yield* DateTime.nowAsDate;
const [invitation] = yield* query((db) =>
db
.select({
status: workspaceInvite.status,
stillIn: workspaceMember.id,
workspaceId: workspaceInvite.workspaceId,
email: workspaceInvite.email,
role: workspaceInvite.role,
Expand All @@ -757,24 +795,45 @@ function invitationFor(userId: string, invitationId: string) {
.from(workspaceInvite)
.innerJoin(workspace, eq(workspace.id, workspaceInvite.workspaceId))
.innerJoin(user, eq(user.id, workspaceInvite.inviterId))
.leftJoin(
workspaceMember,
and(
eq(workspaceMember.workspaceId, workspaceInvite.workspaceId),
eq(workspaceMember.userId, userId),
),
)
.where(
and(
eq(workspaceInvite.id, invitationId),
eq(workspaceInvite.status, "pending"),
gt(workspaceInvite.expiresAt, now),
or(
and(eq(workspaceInvite.status, "pending"), gt(workspaceInvite.expiresAt, now)),
eq(workspaceInvite.status, "accepted"),
),
),
)
.for("update", { of: workspaceInvite }),
);
if (!invitation) return yield* new ResourceHidden({ resource: "invitation" });
const hidden = new ResourceHidden({ resource: "invitation" });
if (!invitation) return yield* hidden;
if (invitation.status === "accepted" && invitation.stillIn === null) return yield* hidden;
const [invitee] = yield* query((db) =>
db
.select({ email: user.email, emailVerified: user.emailVerified })
.from(user)
.where(eq(user.id, userId)),
);
if (invitee?.email.toLowerCase() !== invitation.email) return yield* new NotTheInvitee();
return { ...invitation, inviteeVerified: invitee.emailVerified };
if (invitation.status === "accepted") {
return { status: "accepted" as const, workspaceId: invitation.workspaceId };
}
return {
status: "pending" as const,
workspaceId: invitation.workspaceId,
role: invitation.role,
workspaceName: invitation.workspaceName,
inviterName: invitation.inviterName,
inviteeVerified: invitee.emailVerified,
};
});
}

Expand Down
Loading
Loading