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
10 changes: 10 additions & 0 deletions docs/qa/directory-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
1 change: 1 addition & 0 deletions docs/thesaurus.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion packages/api/src/core/instance/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export {
completeReadyStep,
isImportOpen,
reopenImportStep,
returnToCompanyStepFromDemo,
returnToCompanyStep,
} from "./setup";
export { claimOwner, createOwnerAtSetup, readOwner, type AddApprovedPeople } from "./owner";
export { readInstance } from "./instance";
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/core/instance/app/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ export function reopenImportStep(ctx: SecurityContext, tx: Tx): Promise<boolean>

/**
* 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<boolean> {
return changeStep(ctx, tx, (instance) => instance.returnToCompanyStepFromDemo());
export function returnToCompanyStep(ctx: SecurityContext, tx: Tx): Promise<boolean> {
return changeStep(ctx, tx, (instance) => instance.returnToCompanyStep());
}

/** Whether the import still takes changes: setup has not moved past the import step. */
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/core/instance/domain/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/core/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export {
isImportOpen,
readSetupHolder,
reopenImportStep,
returnToCompanyStepFromDemo,
returnToCompanyStep,
readOwner,
readInstance,
recordOwnerSignInMethod,
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/modules/demo/app/useCases/exitDemo.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
23 changes: 8 additions & 15 deletions packages/api/src/modules/employees/db/employeeRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export interface EmployeeRepository {
insertDemo(demoEmployees: readonly NewDemoEmployee[]): Promise<void>;
/** Inserts employees, skipping emails another employee has; returns how many were inserted. */
insertSkippingTakenEmails(newEmployees: readonly EmployeeRow[]): Promise<number>;
/** 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<void>;
/** The emails among these that an employee has. */
findTakenEmails(emails: readonly string[]): Promise<string[]>;
Expand Down Expand Up @@ -110,7 +110,13 @@ export function employeeRepository(db: Orm | Tx): EmployeeRepository {
},

deleteAll: async (): Promise<void> => {
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<string[]> => {
Expand Down Expand Up @@ -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<void> {
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<Pick<Employee, "avatarFileId">> & { isDemo?: boolean };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UseCaseName, (imports: ImportUseCases, ctx: SecurityContext, tx: Tx) => unknown>;

Expand Down
26 changes: 16 additions & 10 deletions packages/api/src/modules/imports/app/useCases/deleteImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,23 @@ export async function deleteImport(tx: Tx): Promise<void> {

/** Deletes the approved import once setup is complete; its photos stay with the employees. */
export async function finishImport(context: ImportContext, ctx: SecurityContext): Promise<void> {
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;
}
8 changes: 7 additions & 1 deletion packages/api/src/modules/imports/app/useCases/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<void>;
/** Deletes what is left of the approved import in the caller's transaction, when setup is reset. */
deleteFinishedImport: (ctx: SecurityContext, tx: Tx) => Promise<void>;
/** Takes setup from its last step back to the review, so the owner can change it. */
reopenReview: (ctx: SecurityContext) => Promise<void>;
reads: ImportReadService;
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/modules/system/app/useCases/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,11 +12,14 @@ export interface SystemUseCases {
ai: Pick<AiSettings, "read" | "save">;
/** Completes setup from the ready step. */
completeSetup: (ctx: SecurityContext) => Promise<void>;
/** Starts a finished setup over from the company step. */
resetSetup: (ctx: SecurityContext) => Promise<void>;
}

export function createSystemUseCases(context: SystemContext): SystemUseCases {
return {
ai: context.aiSettings,
completeSetup: (ctx: SecurityContext) => completeSetup(context, ctx),
resetSetup: (ctx: SecurityContext) => resetSetup(context, ctx),
};
}
143 changes: 143 additions & 0 deletions packages/api/src/modules/system/app/useCases/resetSetup.int.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
Loading
Loading