diff --git a/docs/qa/directory-page.md b/docs/qa/directory-page.md
index d28b2969..65d5504a 100644
--- a/docs/qa/directory-page.md
+++ b/docs/qa/directory-page.md
@@ -27,3 +27,13 @@ A manual check of the People page against a development stack.
- [ ] `/people?sort=bogus&dir=sideways&after=junk&q=` opens the normal list.
- [ ] With the API failing, the page shows "Couldn’t load people" and "Try again" reloads the list once the API is back.
- [ ] At a narrow canvas the table scrolls sideways inside its frame, the Name column and header row stay pinned.
+
+## Reset setup
+
+1. On a finished setup, open the workspace menu and choose "Reset setup", then "Cancel": nothing changes.
+2. Choose "Reset setup" again and confirm.
+
+- [ ] The dialog reads "Reset setup?" and says everyone in the directory is deleted and setup starts again.
+- [ ] Confirming lands on `/setup/company`, still signed in, with the company name and logo filled in.
+- [ ] Finishing setup again with a new import shows only the new people on `/people`.
+- [ ] During the self-hosted demo the workspace menu shows "Exit demo" and no "Reset setup".
diff --git a/docs/thesaurus.md b/docs/thesaurus.md
index 83cf8696..bb840512 100644
--- a/docs/thesaurus.md
+++ b/docs/thesaurus.md
@@ -37,4 +37,5 @@ The words Broccoli's code and docs use, and what each one means. Only Broccoli's
- **Import employee**: the employee an import will create: the import records that share a work email, or one without an email. Each value is the owner's pick or typed value, else the first source's: files oldest first, then Slack.
- **Ready import employee**: an import employee complete enough to become an employee under the employee rules, with an email no employee has yet. The others are incomplete import employees, or have a taken email.
- **Setup approve**: the setup review step's "Add N people" button, which completes the import step, once. The ready import employees that are not left out become employees, with the values and photos review shows, when setup completes; the others are not added. Until then, "Back" on setup's last step reopens the review. The import is deleted when setup completes.
+- **Reset setup**: the workspace menu action that starts a finished setup over: every employee is deleted and setup goes back to the `company` step; the company and the AI choice stay. The demo's provisional owner exits the demo instead.
- **Slack connection**: the instance's connected Slack workspace and its encrypted token, owned by the `slack` module. Connecting imports nothing; the import reads the workspace when the owner asks.
diff --git a/packages/api/src/core/instance/app/index.ts b/packages/api/src/core/instance/app/index.ts
index da609cbd..620928b7 100644
--- a/packages/api/src/core/instance/app/index.ts
+++ b/packages/api/src/core/instance/app/index.ts
@@ -5,7 +5,7 @@ export {
completeReadyStep,
isImportOpen,
reopenImportStep,
- returnToCompanyStepFromDemo,
+ returnToCompanyStep,
} from "./setup";
export { claimOwner, createOwnerAtSetup, readOwner, type AddApprovedPeople } from "./owner";
export { readInstance } from "./instance";
diff --git a/packages/api/src/core/instance/app/setup.ts b/packages/api/src/core/instance/app/setup.ts
index 2c4808fc..25f7ef76 100644
--- a/packages/api/src/core/instance/app/setup.ts
+++ b/packages/api/src/core/instance/app/setup.ts
@@ -31,10 +31,10 @@ export function reopenImportStep(ctx: SecurityContext, tx: Tx): Promise
/**
* Takes setup from `done` back to the company step when an owner with an account exits the
- * demo, joining `tx`; does nothing at any other step. Returns whether it moved.
+ * demo or resets setup, joining `tx`; does nothing at any other step. Returns whether it moved.
*/
-export function returnToCompanyStepFromDemo(ctx: SecurityContext, tx: Tx): Promise {
- return changeStep(ctx, tx, (instance) => instance.returnToCompanyStepFromDemo());
+export function returnToCompanyStep(ctx: SecurityContext, tx: Tx): Promise {
+ return changeStep(ctx, tx, (instance) => instance.returnToCompanyStep());
}
/** Whether the import still takes changes: setup has not moved past the import step. */
diff --git a/packages/api/src/core/instance/domain/instance.ts b/packages/api/src/core/instance/domain/instance.ts
index 7a390802..e7549abc 100644
--- a/packages/api/src/core/instance/domain/instance.ts
+++ b/packages/api/src/core/instance/domain/instance.ts
@@ -105,7 +105,7 @@ export class Instance {
return this.advanceStep("owner", "import") ?? this.advanceStep("ready", "import");
}
- returnToCompanyStepFromDemo(): Instance | null {
+ returnToCompanyStep(): Instance | null {
return this.advanceStep("done", "company");
}
diff --git a/packages/api/src/core/instance/index.ts b/packages/api/src/core/instance/index.ts
index 30e4182d..c424517d 100644
--- a/packages/api/src/core/instance/index.ts
+++ b/packages/api/src/core/instance/index.ts
@@ -15,7 +15,7 @@ export {
isImportOpen,
readSetupHolder,
reopenImportStep,
- returnToCompanyStepFromDemo,
+ returnToCompanyStep,
readOwner,
readInstance,
recordOwnerSignInMethod,
diff --git a/packages/api/src/modules/demo/app/useCases/exitDemo.ts b/packages/api/src/modules/demo/app/useCases/exitDemo.ts
index 1d9be4d8..20d31787 100644
--- a/packages/api/src/modules/demo/app/useCases/exitDemo.ts
+++ b/packages/api/src/modules/demo/app/useCases/exitDemo.ts
@@ -1,5 +1,5 @@
import { ApplicationError } from "../../../../core/applicationError";
-import { readInstance, returnToCompanyStepFromDemo } from "../../../../core/instance";
+import { readInstance, returnToCompanyStep } from "../../../../core/instance";
import type { SecurityContext } from "../../../../core/security";
import { deleteAllEmployees, hasDemoEmployees } from "../../../employees";
import type { DemoContext } from "./demoContext";
@@ -16,7 +16,7 @@ export async function exitDemo({ transact }: DemoContext, ctx: SecurityContext)
throw new ApplicationError("FORBIDDEN", "Exit the demo through setup.");
}
// Moving the step first locks the instance row, so a concurrent exit waits and then refuses.
- if (!(await returnToCompanyStepFromDemo(ctx, tx)) || !(await hasDemoEmployees(ctx, tx))) {
+ if (!(await returnToCompanyStep(ctx, tx)) || !(await hasDemoEmployees(ctx, tx))) {
throw new ApplicationError("CONFLICT", "The instance holds no demo data.");
}
await deleteAllEmployees(ctx, tx);
diff --git a/packages/api/src/modules/employees/app/importedEmployees.int.test.ts b/packages/api/src/modules/employees/app/importedEmployees.int.test.ts
index 2c29ad86..55be5870 100644
--- a/packages/api/src/modules/employees/app/importedEmployees.int.test.ts
+++ b/packages/api/src/modules/employees/app/importedEmployees.int.test.ts
@@ -133,21 +133,16 @@ test("assigns avatars only to complete employees with untaken emails, keeping th
).toEqual([{ email: avery.email, avatarFileId: keptPhoto }]);
});
-test("deleting all employees deletes the demo avatars and keeps the import's photos", async ({
- orm,
- seed,
- transact,
-}) => {
+test("deleting all employees deletes their avatars", async ({ orm, seed, transact }) => {
await employeeRepository(orm).insert({
...parseNewEmployee({ firstName: "Demo", lastName: "Employee", email: "demo@example.com" }),
avatarFileId: await seed.file({ content: PNG, mimeType: "image/png" }),
isDemo: true,
});
- const importPhoto = await seed.file({ content: PNG, mimeType: "image/png" });
- await seed.employee({ avatarFileId: importPhoto });
+ await seed.employee({ avatarFileId: await seed.file({ content: PNG, mimeType: "image/png" }) });
await transact((tx) => deleteAllEmployees(OWNER, tx));
expect((await createEmployeeUseCases({ orm }).listEmployees(OWNER, {})).edges).toEqual([]);
- expect(await storedFileIds(orm)).toEqual([importPhoto]);
+ expect(await storedFileIds(orm)).toEqual([]);
});
diff --git a/packages/api/src/modules/employees/db/employeeRepository.ts b/packages/api/src/modules/employees/db/employeeRepository.ts
index 10c91fff..d3eda178 100644
--- a/packages/api/src/modules/employees/db/employeeRepository.ts
+++ b/packages/api/src/modules/employees/db/employeeRepository.ts
@@ -33,7 +33,7 @@ export interface EmployeeRepository {
insertDemo(demoEmployees: readonly NewDemoEmployee[]): Promise;
/** Inserts employees, skipping emails another employee has; returns how many were inserted. */
insertSkippingTakenEmails(newEmployees: readonly EmployeeRow[]): Promise;
- /** Deletes every employee and the demo avatars; an imported avatar stays with the import that stored it. */
+ /** Deletes every employee and their avatars; a file another row still references fails it. */
deleteAll(): Promise;
/** The emails among these that an employee has. */
findTakenEmails(emails: readonly string[]): Promise;
@@ -110,7 +110,13 @@ export function employeeRepository(db: Orm | Tx): EmployeeRepository {
},
deleteAll: async (): Promise => {
- await deleteDemoAvatars(db, await orm().delete(employees).returning(avatarColumns));
+ const deleted = await orm()
+ .delete(employees)
+ .returning({ avatarFileId: employees.avatarFileId });
+ await deleteFiles(
+ db,
+ deleted.flatMap(({ avatarFileId }) => (avatarFileId ? [avatarFileId] : [])),
+ );
},
findTakenEmails: async (emails: readonly string[]): Promise => {
@@ -161,19 +167,6 @@ export function employeeRepository(db: Orm | Tx): EmployeeRepository {
/** Rows per insert, well below Postgres's limit of 65,535 parameters. */
const INSERT_BATCH_SIZE = 1_000;
-const avatarColumns = { avatarFileId: employees.avatarFileId, isDemo: employees.isDemo };
-
-/** Deletes the avatars of the deleted demo employees, whose rows no longer reference them. */
-async function deleteDemoAvatars(
- db: Orm | Tx,
- deleted: { avatarFileId: string | null; isDemo: boolean }[],
-): Promise {
- await deleteFiles(
- db,
- deleted.flatMap(({ avatarFileId, isDemo }) => (isDemo && avatarFileId ? [avatarFileId] : [])),
- );
-}
-
/** A new employee as stored, with the columns only the module itself sets. */
type EmployeeRow = NewEmployee & Partial> & { isDemo?: boolean };
diff --git a/packages/api/src/modules/imports/app/useCases/access.int.test.ts b/packages/api/src/modules/imports/app/useCases/access.int.test.ts
index 92d0123c..e4ff78ad 100644
--- a/packages/api/src/modules/imports/app/useCases/access.int.test.ts
+++ b/packages/api/src/modules/imports/app/useCases/access.int.test.ts
@@ -61,6 +61,7 @@ const calls = {
addApprovedPeople: (imports, ctx, tx) => imports.addApprovedPeople(ctx, tx),
approveImport: (imports, ctx) => imports.approveImport(ctx, "any-version"),
finishImport: (imports, ctx) => imports.finishImport(ctx),
+ deleteFinishedImport: (imports, ctx, tx) => imports.deleteFinishedImport(ctx, tx),
reopenReview: (imports, ctx) => imports.reopenReview(ctx),
} satisfies Record unknown>;
diff --git a/packages/api/src/modules/imports/app/useCases/deleteImport.ts b/packages/api/src/modules/imports/app/useCases/deleteImport.ts
index 8c296d50..0bd51de9 100644
--- a/packages/api/src/modules/imports/app/useCases/deleteImport.ts
+++ b/packages/api/src/modules/imports/app/useCases/deleteImport.ts
@@ -13,17 +13,23 @@ export async function deleteImport(tx: Tx): Promise {
/** Deletes the approved import once setup is complete; its photos stay with the employees. */
export async function finishImport(context: ImportContext, ctx: SecurityContext): Promise {
- requireImportAccess(ctx);
- const deletedPhotos = await context.transact(async (tx) => {
- const imports = await lockImport(tx);
- const employees = await findEmployeesWithAvatars(ctx, tx);
- const deleted = await importPhotoRepository(tx).deletePhotosExcept(
- employees.flatMap(({ avatarFileId }) => avatarFileId ?? []),
- );
- await imports.deleteAll({ keepPhotos: true });
- return deleted;
- });
+ const deletedPhotos = await context.transact((tx) => deleteFinishedImport(ctx, tx));
for (const photo of deletedPhotos) {
context.logger.info(photo, "Deleted an unused import photo");
}
}
+
+/**
+ * Deletes the approved import, joining `tx`, and its photos but the employees' avatars; returns
+ * the deleted photos. Setup's reset also runs it, in case the cleanup after completion did not.
+ */
+export async function deleteFinishedImport(ctx: SecurityContext, tx: Tx) {
+ requireImportAccess(ctx);
+ const imports = await lockImport(tx);
+ const employees = await findEmployeesWithAvatars(ctx, tx);
+ const deleted = await importPhotoRepository(tx).deletePhotosExcept(
+ employees.flatMap(({ avatarFileId }) => avatarFileId ?? []),
+ );
+ await imports.deleteAll({ keepPhotos: true });
+ return deleted;
+}
diff --git a/packages/api/src/modules/imports/app/useCases/index.ts b/packages/api/src/modules/imports/app/useCases/index.ts
index febe73af..6f042669 100644
--- a/packages/api/src/modules/imports/app/useCases/index.ts
+++ b/packages/api/src/modules/imports/app/useCases/index.ts
@@ -1,3 +1,4 @@
+import type { Tx } from "../../../../core/db";
import type { Decisions } from "../../../../integrations/ai";
import type { OriginalFileReader } from "../../../../core/instance";
import type { JobQueue } from "../../../../core/jobs";
@@ -10,7 +11,7 @@ import { createEmployeeChoiceService, type EmployeeChoiceService } from "./emplo
import { createFileSourceService, type FileSourceService } from "./fileSourceService";
import type { DemoFill, ImportSlack } from "./importContext";
import { createImportReadService, type ImportReadService } from "./importReadService";
-import { finishImport } from "./deleteImport";
+import { deleteFinishedImport, finishImport } from "./deleteImport";
import { readSlack } from "./readSlack";
import { removeSource } from "./removeSource";
import { reopenReview } from "./reopenReview";
@@ -46,6 +47,8 @@ export interface ImportUseCases {
reportAddedPeople: (added: AddedPeople) => void;
/** Deletes the approved import once setup is complete; its photos stay with the employees. */
finishImport: (ctx: SecurityContext) => Promise;
+ /** Deletes what is left of the approved import in the caller's transaction, when setup is reset. */
+ deleteFinishedImport: (ctx: SecurityContext, tx: Tx) => Promise;
/** Takes setup from its last step back to the review, so the owner can change it. */
reopenReview: (ctx: SecurityContext) => Promise;
reads: ImportReadService;
@@ -84,6 +87,9 @@ export function createImportUseCases({
addApprovedPeople,
reportAddedPeople: (added: AddedPeople) => reportAddedPeople(context, added),
finishImport: (ctx: SecurityContext) => finishImport(context, ctx),
+ deleteFinishedImport: async (ctx: SecurityContext, tx: Tx) => {
+ await deleteFinishedImport(ctx, tx);
+ },
reopenReview: (ctx: SecurityContext) => reopenReview(context, ctx),
reads: createImportReadService(orm),
choices: createEmployeeChoiceService(transact),
diff --git a/packages/api/src/modules/index.ts b/packages/api/src/modules/index.ts
index c3bdb783..a5b80890 100644
--- a/packages/api/src/modules/index.ts
+++ b/packages/api/src/modules/index.ts
@@ -41,6 +41,8 @@ export function createModules(
mode: deployment.mode,
aiSettings,
addApprovedPeople: importUseCases.addApprovedPeople,
+ deleteAllEmployees,
+ deleteFinishedImport: importUseCases.deleteFinishedImport,
}),
employeesModule({ orm: core.orm }),
companyModule({ ...core, brandLookupUrl }),
diff --git a/packages/api/src/modules/system/app/useCases/index.ts b/packages/api/src/modules/system/app/useCases/index.ts
index c6323f42..4597c59a 100644
--- a/packages/api/src/modules/system/app/useCases/index.ts
+++ b/packages/api/src/modules/system/app/useCases/index.ts
@@ -1,6 +1,7 @@
import type { AiSettings } from "../../../../core/instance";
import type { SecurityContext } from "../../../../core/security";
import { completeSetup } from "./completeSetup";
+import { resetSetup } from "./resetSetup";
import type { SystemContext } from "./systemContext";
export type { SystemContext } from "./systemContext";
@@ -11,11 +12,14 @@ export interface SystemUseCases {
ai: Pick;
/** Completes setup from the ready step. */
completeSetup: (ctx: SecurityContext) => Promise;
+ /** Starts a finished setup over from the company step. */
+ resetSetup: (ctx: SecurityContext) => Promise;
}
export function createSystemUseCases(context: SystemContext): SystemUseCases {
return {
ai: context.aiSettings,
completeSetup: (ctx: SecurityContext) => completeSetup(context, ctx),
+ resetSetup: (ctx: SecurityContext) => resetSetup(context, ctx),
};
}
diff --git a/packages/api/src/modules/system/app/useCases/resetSetup.int.test.ts b/packages/api/src/modules/system/app/useCases/resetSetup.int.test.ts
new file mode 100644
index 00000000..f8cf766f
--- /dev/null
+++ b/packages/api/src/modules/system/app/useCases/resetSetup.int.test.ts
@@ -0,0 +1,143 @@
+import {
+ companyUseCases,
+ createEmployeeUseCases,
+ expect,
+ importUseCases,
+ NON_OWNER,
+ PNG,
+ SLACK_PHOTO_URL,
+ storedFileIds,
+ systemUseCases,
+ test,
+ TEST_OWNER,
+} from "#test/support";
+
+import { readInstance, readOwner } from "../../../../core/instance";
+
+test("resetting a finished setup deletes every employee and avatar and keeps the company, AI choice and owner", async ({
+ orm,
+ seed,
+}) => {
+ const owner = await seed.owner({ password: TEST_OWNER.password });
+ const company = companyUseCases(orm);
+ await company.update(owner, { name: "Acme Corp" });
+ await seed.aiSource("broccoli_ai");
+ await seed.setupStep("done");
+ const firstAvatar = await seed.file({ content: PNG, mimeType: "image/png" });
+ const secondAvatar = await seed.file({ content: PNG, mimeType: "image/png" });
+ await seed.employee({ avatarFileId: firstAvatar });
+ await seed.employee({ avatarFileId: secondAvatar });
+ await seed.employee();
+ const originalOwner = await readOwner(orm);
+
+ await systemUseCases(orm).resetSetup(owner);
+
+ expect((await createEmployeeUseCases({ orm }).listEmployees(owner, {})).edges).toEqual([]);
+ expect(await storedFileIds(orm)).toEqual([]);
+ expect(await readInstance(orm)).toMatchObject({
+ step: "company",
+ aiSource: "broccoli_ai",
+ ownerUserId: owner.user!.userId,
+ });
+ expect(await readOwner(orm)).toEqual(originalOwner);
+ expect(await company.getCompany(owner)).toMatchObject({ name: "Acme Corp" });
+});
+
+test("resets a finished setup whose import was not cleaned up yet", async ({ orm, seed }) => {
+ await seed.assignment();
+ const owner = await seed.owner();
+ await seed.setupStep("import");
+ const source = await seed.importSource();
+ const records = await seed.importEmployees(source.id, [{ photoUrl: SLACK_PHOTO_URL }]);
+ const avatarFileId = await seed.importPhoto(records[0]!.id, {
+ photoUrl: SLACK_PHOTO_URL,
+ content: PNG,
+ mimeType: "image/png",
+ });
+ const imports = importUseCases(orm);
+ const system = systemUseCases(orm);
+ const employees = createEmployeeUseCases({ orm });
+ await imports.approveImport(owner, (await imports.reads.readReview(owner)).version);
+ await system.completeSetup(owner);
+ expect((await employees.listEmployees(owner, {})).edges).toMatchObject([
+ { node: { avatarFileId } },
+ ]);
+ expect(await imports.reads.listSources(owner)).toHaveLength(1);
+
+ await system.resetSetup(owner);
+
+ expect((await readInstance(orm)).step).toBe("company");
+ expect((await employees.listEmployees(owner, {})).edges).toEqual([]);
+ expect(await storedFileIds(orm)).toEqual([]);
+ expect(await imports.reads.listSources(owner)).toEqual([]);
+ expect((await imports.reads.readReview(owner)).listEmployees({}).edges).toEqual([]);
+});
+
+test("refuses a non-owner resetting setup", async ({ orm, seed }) => {
+ await seed.owner();
+ await seed.setupStep("done");
+
+ await expect(systemUseCases(orm).resetSetup(NON_OWNER)).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ });
+
+ expect((await readInstance(orm)).step).toBe("done");
+});
+
+test("refuses resetting before setup is done and keeps the employees", async ({ orm, seed }) => {
+ const owner = await seed.owner();
+ await seed.setupStep("import");
+ const employee = await seed.employee();
+
+ await expect(systemUseCases(orm).resetSetup(owner)).rejects.toMatchObject({
+ code: "CONFLICT",
+ message: "Setup is not done.",
+ });
+
+ expect((await readInstance(orm)).step).toBe("import");
+ const { edges } = await createEmployeeUseCases({ orm }).listEmployees(owner, {});
+ expect(edges.map(({ node }) => node)).toEqual([employee]);
+});
+
+test("refuses resetting the self-hosted demo and keeps its owner and employees", async ({
+ orm,
+ seed,
+}) => {
+ const demoExpiresAt = new Date("2099-10-20T12:00:00Z");
+ const owner = await seed.demoOwner({ expiresAt: demoExpiresAt });
+ const avatarFileId = await seed.file({ content: PNG, mimeType: "image/png" });
+ const employee = await seed.employee({ avatarFileId });
+
+ await expect(systemUseCases(orm).resetSetup(owner)).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message: "Exit the demo instead.",
+ });
+
+ expect(await readInstance(orm)).toMatchObject({
+ step: "done",
+ ownerUserId: owner.user!.userId,
+ demoExpiresAt,
+ });
+ const { edges } = await createEmployeeUseCases({ orm }).listEmployees(owner, {});
+ expect(edges.map(({ node }) => node)).toEqual([employee]);
+ expect(await storedFileIds(orm)).toEqual([avatarFileId]);
+});
+
+test("refuses a concurrent second reset", async ({ orm, seed }) => {
+ await seed.assignment();
+ const owner = await seed.owner();
+ await seed.setupStep("done");
+ await seed.employee();
+ const system = systemUseCases(orm);
+
+ const results = await Promise.allSettled([system.resetSetup(owner), system.resetSetup(owner)]);
+
+ expect(results.filter(({ status }) => status === "fulfilled")).toEqual([
+ { status: "fulfilled", value: undefined },
+ ]);
+ expect(results.filter(({ status }) => status === "rejected")).toMatchObject([
+ { reason: { code: "CONFLICT", message: "Setup is not done." } },
+ ]);
+ expect((await readInstance(orm)).step).toBe("company");
+ expect((await createEmployeeUseCases({ orm }).listEmployees(owner, {})).edges).toEqual([]);
+});
diff --git a/packages/api/src/modules/system/app/useCases/resetSetup.ts b/packages/api/src/modules/system/app/useCases/resetSetup.ts
new file mode 100644
index 00000000..aed3be15
--- /dev/null
+++ b/packages/api/src/modules/system/app/useCases/resetSetup.ts
@@ -0,0 +1,34 @@
+import { ApplicationError } from "../../../../core/applicationError";
+import { readInstance, returnToCompanyStep } from "../../../../core/instance";
+import type { SecurityContext } from "../../../../core/security";
+import type { SystemContext } from "./systemContext";
+
+/**
+ * Starts a finished setup over: deletes every employee and takes setup back to the company
+ * step, in one transaction; the company, the AI choice and the owner stay. Refused at any step
+ * but `done`, and during the self-hosted demo, whose owner exits the demo instead.
+ */
+export async function resetSetup(
+ {
+ transact,
+ deleteAllEmployees,
+ deleteFinishedImport,
+ logger,
+ }: Pick,
+ ctx: SecurityContext,
+): Promise {
+ ctx.requireOwnerOrSystem();
+ await transact(async (tx) => {
+ if ((await readInstance(tx)).demoExpiresAt) {
+ throw new ApplicationError("FORBIDDEN", "Exit the demo instead.");
+ }
+ // Moving the step first locks the instance row, so a concurrent reset waits and then refuses.
+ if (!(await returnToCompanyStep(ctx, tx))) {
+ throw new ApplicationError("CONFLICT", "Setup is not done.");
+ }
+ // A lost completion event can leave the import holding the employees' avatars.
+ await deleteFinishedImport(ctx, tx);
+ await deleteAllEmployees(ctx, tx);
+ });
+ logger.info("Reset setup");
+}
diff --git a/packages/api/src/modules/system/app/useCases/systemContext.ts b/packages/api/src/modules/system/app/useCases/systemContext.ts
index b7ba757f..19190403 100644
--- a/packages/api/src/modules/system/app/useCases/systemContext.ts
+++ b/packages/api/src/modules/system/app/useCases/systemContext.ts
@@ -1,11 +1,17 @@
import type { Mode } from "../../../../core/env";
import type { AddApprovedPeople, AiSettings } from "../../../../core/instance";
+import type { Tx } from "../../../../core/db";
import type { ModuleDependencies } from "../../../../core/moduleManifest";
+import type { SecurityContext } from "../../../../core/security";
/** What the system use cases work with. */
-export interface SystemContext extends Pick {
+export interface SystemContext extends Pick {
mode: Mode;
aiSettings: AiSettings;
/** Adds the people the owner approved when managed setup completes. */
addApprovedPeople: AddApprovedPeople;
+ /** Deletes every employee when the owner resets setup. */
+ deleteAllEmployees: (ctx: SecurityContext, tx: Tx) => Promise;
+ /** Deletes what is left of the approved import when the owner resets setup. */
+ deleteFinishedImport: (ctx: SecurityContext, tx: Tx) => Promise;
}
diff --git a/packages/api/src/modules/system/graphql/resolvers.ts b/packages/api/src/modules/system/graphql/resolvers.ts
index 57d3cef6..fae0aae2 100644
--- a/packages/api/src/modules/system/graphql/resolvers.ts
+++ b/packages/api/src/modules/system/graphql/resolvers.ts
@@ -58,6 +58,16 @@ export function createResolvers(orm: Orm, mode: Mode, useCases: SystemUseCases):
},
["CONFLICT"],
),
+ reset: (_parent, _args, context) =>
+ mutationPayload(
+ "setup",
+ async () => {
+ const actor = context.getActor();
+ await useCases.resetSetup(actor);
+ return readSetup(actor);
+ },
+ ["CONFLICT"],
+ ),
saveAiSource: (_parent, { input }, context) =>
mutationPayload(
"setup",
diff --git a/packages/api/src/modules/system/graphql/schema.graphql b/packages/api/src/modules/system/graphql/schema.graphql
index a2c83329..2d305d0e 100644
--- a/packages/api/src/modules/system/graphql/schema.graphql
+++ b/packages/api/src/modules/system/graphql/schema.graphql
@@ -108,6 +108,8 @@ type SetupMutations {
saveAiSource(input: AiSourceInput!): SetupSaveAiSourcePayload!
"Completes setup from the ready step and adds the people the owner approved; refused with a ConflictError at any other step, such as after another tab went back to the review. Owner only."
complete: SetupCompletePayload!
+ "Starts a finished setup over: deletes every person and takes setup back to the company step; the company and the AI choice stay. Refused with a ConflictError before setup is done, and during the self-hosted demo. Owner only."
+ reset: SetupResetPayload!
}
"Setup after a change."
@@ -123,6 +125,14 @@ type SetupCompletePayload {
union SetupCompleteError = ConflictError
+"Setup after starting it over."
+type SetupResetPayload {
+ setup: Setup
+ error: SetupResetError
+}
+
+union SetupResetError = ConflictError
+
"Setup after choosing how files are read."
type SetupSaveAiSourcePayload {
setup: Setup
diff --git a/packages/api/test/e2e/resetSetup.test.ts b/packages/api/test/e2e/resetSetup.test.ts
new file mode 100644
index 00000000..8d086ef0
--- /dev/null
+++ b/packages/api/test/e2e/resetSetup.test.ts
@@ -0,0 +1,38 @@
+import { expect, signInAsManagedOwner, startManagedEnv, test } from "#test/support";
+
+test("a managed owner resets setup through the API and stays signed in", async () => {
+ const { env, accounts } = await startManagedEnv();
+ const browser = await signInAsManagedOwner(env, accounts);
+ await env.seed.setupStep("done");
+ const before = await browser.graphql("query { me { id isOwner } }");
+ const signedIn = (await before.json()) as { data: { me: { id: string; isOwner: boolean } } };
+ expect(signedIn).not.toHaveProperty("errors");
+ expect(signedIn.data.me.id).toEqual(expect.any(String));
+ expect(signedIn.data.me.isOwner).toBe(true);
+
+ const response = await browser.graphql(resetSetup);
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ data: { setup: { reset: { setup: { id: "instance", step: "COMPANY" }, error: null } } },
+ });
+ const after = await browser.graphql("query { me { id isOwner } }");
+ expect(await after.json()).toEqual({ data: { me: signedIn.data.me } });
+});
+
+test("refuses resetting setup without a session", async ({ env }) => {
+ const response = await env.browser().graphql(resetSetup);
+
+ expect(await response.json()).toMatchObject({
+ errors: [{ extensions: { code: "UNAUTHENTICATED" } }],
+ });
+});
+
+const resetSetup = `mutation {
+ setup {
+ reset {
+ setup { id step }
+ error { __typename ... on Error { message } }
+ }
+ }
+}`;
diff --git a/packages/api/test/support/useCases.ts b/packages/api/test/support/useCases.ts
index 24a4f493..ba55df23 100644
--- a/packages/api/test/support/useCases.ts
+++ b/packages/api/test/support/useCases.ts
@@ -20,6 +20,7 @@ import { type BrandLookup } from "../../src/integrations/brandLookup";
import type { SlackApi, SlackAuthorization } from "../../src/integrations/slack";
import { createCompanyUseCases } from "../../src/modules/company";
import { createDemoUseCases, prepareDemoFill } from "../../src/modules/demo";
+import { deleteAllEmployees } from "../../src/modules/employees";
import { createImportUseCases } from "../../src/modules/imports";
import { createExternalAgentUseCases } from "../../src/modules/externalAgents";
import { createSystemUseCases } from "../../src/modules/system";
@@ -50,8 +51,11 @@ export function systemUseCases(orm: Orm) {
return createSystemUseCases({
orm,
transact: testTransactor(orm),
+ logger: createLogger("silent"),
mode: "self_hosted",
addApprovedPeople: importUseCases(orm).addApprovedPeople,
+ deleteAllEmployees,
+ deleteFinishedImport: importUseCases(orm).deleteFinishedImport,
aiSettings: aiSettings(orm),
});
}
diff --git a/packages/web/importRules.json b/packages/web/importRules.json
index 78cdb5fe..0e6604f0 100644
--- a/packages/web/importRules.json
+++ b/packages/web/importRules.json
@@ -16,6 +16,7 @@
"src/components/ui",
"src/features/auth",
"src/features/demo",
+ "src/features/setup",
"src/i18n",
"src/lib/useSessionChange"
],
diff --git a/packages/web/src/features/setup/ResetSetupDialog.tsx b/packages/web/src/features/setup/ResetSetupDialog.tsx
new file mode 100644
index 00000000..692868f0
--- /dev/null
+++ b/packages/web/src/features/setup/ResetSetupDialog.tsx
@@ -0,0 +1,73 @@
+import { useState } from "react";
+
+import { ResetSetupDocument } from "@/__generated__/graphql";
+import { usePayloadMutation } from "@/apollo";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { t } from "@/i18n";
+import { useSessionChange } from "@/lib/useSessionChange";
+
+type Props = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+/**
+ * Confirms resetting a finished setup: every person is deleted and setup goes back to the
+ * company step, where routing sends the owner, who stays signed in.
+ */
+export function ResetSetupDialog({ open, onOpenChange }: Props) {
+ const [resetSetup] = usePayloadMutation(ResetSetupDocument);
+ const [failed, setFailed] = useState(false);
+ const [confirm, resetting] = useSessionChange(async () => {
+ setFailed(false);
+ const reset = await resetSetup().then(
+ () => true,
+ () => false,
+ );
+ setFailed(!reset);
+ return reset;
+ });
+
+ const changeOpen = (nextOpen: boolean) => {
+ if (resetting) return;
+ setFailed(false);
+ onOpenChange(nextOpen);
+ };
+
+ return (
+
+
+
+ {t("setup.reset.title")}
+ {t("setup.reset.detail")}
+
+ {failed && (
+
+ {t("setup.reset.error")}
+
+ )}
+
+ {t("setup.reset.cancel")}
+
+ {resetting ? t("setup.reset.pending") : t("setup.reset.confirm")}
+
+
+
+
+ );
+}
diff --git a/packages/web/src/features/setup/index.ts b/packages/web/src/features/setup/index.ts
index 30b04958..eccfe5f9 100644
--- a/packages/web/src/features/setup/index.ts
+++ b/packages/web/src/features/setup/index.ts
@@ -1,6 +1,7 @@
export { CompanyStepForm } from "./CompanyStepForm";
export { OwnerSetupForm } from "./OwnerSetupForm";
export { readSession } from "./readSession";
+export { ResetSetupDialog } from "./ResetSetupDialog";
export { SetupInProgress } from "./SetupInProgress";
export { SetupStepForm } from "./SetupStepForm";
export { requireFinishedSetup, requireSetupPage, SetupLayout, setupStepPage } from "./SetupLayout";
diff --git a/packages/web/src/features/setup/resetSetup.graphql b/packages/web/src/features/setup/resetSetup.graphql
new file mode 100644
index 00000000..45cecfc4
--- /dev/null
+++ b/packages/web/src/features/setup/resetSetup.graphql
@@ -0,0 +1,16 @@
+mutation ResetSetup {
+ setup {
+ reset {
+ setup {
+ id
+ step
+ }
+ error {
+ __typename
+ ... on Error {
+ message
+ }
+ }
+ }
+ }
+}
diff --git a/packages/web/src/i18n/en/setup.ts b/packages/web/src/i18n/en/setup.ts
index b0c705d2..87825534 100644
--- a/packages/web/src/i18n/en/setup.ts
+++ b/packages/web/src/i18n/en/setup.ts
@@ -12,6 +12,14 @@ export const setupEn = {
"setup.owner.title": "Create your account",
"setup.owner.heading": "Create owner account",
"setup.reopenFailed": "Couldn’t go back to the review. Try again.",
+ "setup.reset.open": "Reset setup",
+ "setup.reset.title": "Reset setup?",
+ "setup.reset.detail":
+ "Everyone in your directory is deleted, and setup starts again. Your company details stay.",
+ "setup.reset.cancel": "Cancel",
+ "setup.reset.confirm": "Reset setup",
+ "setup.reset.pending": "Resetting…",
+ "setup.reset.error": "Couldn’t reset setup. Try again.",
"setup.owner.email": "Your email",
"setup.owner.emailPlaceholder": "name@company.com",
"setup.owner.emailHint": "Used to sign in to this workspace.",
diff --git a/packages/web/src/shell/WorkspaceMenu.tsx b/packages/web/src/shell/WorkspaceMenu.tsx
index ebce258d..f80c60f5 100644
--- a/packages/web/src/shell/WorkspaceMenu.tsx
+++ b/packages/web/src/shell/WorkspaceMenu.tsx
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { signOut } from "@/features/auth";
import { ExitDemoDialog } from "@/features/demo";
+import { ResetSetupDialog } from "@/features/setup";
import { t } from "@/i18n";
import { useSessionChange } from "@/lib/useSessionChange";
@@ -25,6 +26,7 @@ export function WorkspaceMenu() {
const openedOnPath = useRef(null);
// Outside the menu, which closes when an item is chosen.
const [exitingDemo, setExitingDemo] = useState(false);
+ const [resettingSetup, setResettingSetup] = useState(false);
const [signOutFailed, setSignOutFailed] = useState(false);
// The gate then sends the signed-out user to the login page.
const [signOutAndReload, signingOut] = useSessionChange(async () => {
@@ -82,6 +84,13 @@ export function WorkspaceMenu() {
{t("workspace.company")}
+ {/* The demo's owner exits the demo instead. */}
+ {!demoExpiresAt && (
+ setResettingSetup(true)}>
+
+ {t("setup.reset.open")}
+
+ )}
{demoExpiresAt ? (
// The demo's owner has no password, so signing out would lock them out.
@@ -111,6 +120,7 @@ export function WorkspaceMenu() {
)}
+
>
);
}