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() {

)} + ); }