From 8e6d5ff8679e4c7e177e5cd3c5235c8d58356224 Mon Sep 17 00:00:00 2001 From: Liam Lloyd-Tucker Date: Tue, 15 Sep 2026 14:37:07 -0700 Subject: [PATCH 1/2] Add share invite acceptance logic In preparation for adding an archive creation endpoint, this commit adds logic for accepting share invites, which is sometimes triggered by archive creation. --- .../invite/fixtures/create_test_accounts.sql | 19 ++ .../invite/fixtures/create_test_archives.sql | 21 ++ .../fixtures/create_test_folder_links.sql | 65 +++++ .../invite/fixtures/create_test_folders.sql | 30 ++ .../invite/fixtures/create_test_invites.sql | 159 ++++++++++ .../invite/fixtures/create_test_records.sql | 19 ++ packages/api/src/invite/models.ts | 8 + .../create_access_grants_recursive.sql | 57 ++++ .../api/src/invite/queries/create_share.sql | 48 +++ .../queries/get_pending_invites_by_email.sql | 13 + .../queries/update_invite_share_status.sql | 3 + .../invite/queries/update_invite_status.sql | 3 + packages/api/src/invite/service.test.ts | 276 ++++++++++++++++++ packages/api/src/invite/service.ts | 115 ++++++++ 14 files changed, 836 insertions(+) create mode 100644 packages/api/src/invite/fixtures/create_test_accounts.sql create mode 100644 packages/api/src/invite/fixtures/create_test_archives.sql create mode 100644 packages/api/src/invite/fixtures/create_test_folder_links.sql create mode 100644 packages/api/src/invite/fixtures/create_test_folders.sql create mode 100644 packages/api/src/invite/fixtures/create_test_invites.sql create mode 100644 packages/api/src/invite/fixtures/create_test_records.sql create mode 100644 packages/api/src/invite/models.ts create mode 100644 packages/api/src/invite/queries/create_access_grants_recursive.sql create mode 100644 packages/api/src/invite/queries/create_share.sql create mode 100644 packages/api/src/invite/queries/get_pending_invites_by_email.sql create mode 100644 packages/api/src/invite/queries/update_invite_share_status.sql create mode 100644 packages/api/src/invite/queries/update_invite_status.sql create mode 100644 packages/api/src/invite/service.test.ts create mode 100644 packages/api/src/invite/service.ts diff --git a/packages/api/src/invite/fixtures/create_test_accounts.sql b/packages/api/src/invite/fixtures/create_test_accounts.sql new file mode 100644 index 00000000..7ccff06c --- /dev/null +++ b/packages/api/src/invite/fixtures/create_test_accounts.sql @@ -0,0 +1,19 @@ +INSERT INTO account ( + accountid, primaryemail, status, notificationpreferences, type, fullname +) VALUES +( + 1, + 'inviter@permanent.org', + 'status.auth.ok', + '{}', + 'type.account.standard', + 'Inviter Account' +), +( + 2, + 'newaccount@permanent.org', + 'status.auth.ok', + '{}', + 'type.account.standard', + 'New Account' +); diff --git a/packages/api/src/invite/fixtures/create_test_archives.sql b/packages/api/src/invite/fixtures/create_test_archives.sql new file mode 100644 index 00000000..46ca3d12 --- /dev/null +++ b/packages/api/src/invite/fixtures/create_test_archives.sql @@ -0,0 +1,21 @@ +INSERT INTO archive ( + archiveid, archivenbr, public, type, status, createddt, updateddt +) VALUES +( + 1, + '0000-0000', + FALSE, + 'type.archive.person', + 'status.generic.ok', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 2, + '0001-0000', + FALSE, + 'type.archive.person', + 'status.generic.ok', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); diff --git a/packages/api/src/invite/fixtures/create_test_folder_links.sql b/packages/api/src/invite/fixtures/create_test_folder_links.sql new file mode 100644 index 00000000..809789b2 --- /dev/null +++ b/packages/api/src/invite/fixtures/create_test_folder_links.sql @@ -0,0 +1,65 @@ +INSERT INTO folder_link ( + folder_linkid, + folderid, + parentfolderid, + parentfolder_linkid, + archiveid, + position, + accessrole, + status, + type, + createddt, + updateddt +) VALUES +( + 1, + 1, + NULL, + NULL, + 1, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 2, + 2, + 1, + 1, + 1, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO folder_link ( + folder_linkid, + recordid, + parentfolderid, + parentfolder_linkid, + archiveid, + position, + accessrole, + status, + type, + createddt, + updateddt +) VALUES ( + 3, + 1, + NULL, + NULL, + 1, + 2, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); diff --git a/packages/api/src/invite/fixtures/create_test_folders.sql b/packages/api/src/invite/fixtures/create_test_folders.sql new file mode 100644 index 00000000..3ca842ac --- /dev/null +++ b/packages/api/src/invite/fixtures/create_test_folders.sql @@ -0,0 +1,30 @@ +INSERT INTO folder ( + folderid, + archiveid, + displayname, + downloadname, + status, + type, + createddt, + updateddt +) VALUES +( + 1, + 1, + 'Shared Folder', + 'Shared Folder', + 'status.generic.ok', + 'type.folder.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 2, + 1, + 'Nested Folder', + 'Nested Folder', + 'status.generic.ok', + 'type.folder.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); diff --git a/packages/api/src/invite/fixtures/create_test_invites.sql b/packages/api/src/invite/fixtures/create_test_invites.sql new file mode 100644 index 00000000..c82d7cbc --- /dev/null +++ b/packages/api/src/invite/fixtures/create_test_invites.sql @@ -0,0 +1,159 @@ +INSERT INTO invite ( + inviteid, + email, + byarchiveid, + byaccountid, + token, + status, + type, + createddt, + updateddt +) VALUES +( + 1, + 'newaccount@permanent.org', + 1, + 1, + 'token-1', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 2, + 'newaccount@permanent.org', + 1, + 1, + 'token-2', + 'status.invite.revoked', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 3, + 'nullfields@permanent.org', + 1, + 1, + 'token-3', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 4, + 'recordshare@permanent.org', + 1, + 1, + 'token-4', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 5, + 'newaccount@permanent.org', + 1, + 1, + 'token-5', + 'status.invite.pending', + 'type.invite.invite_early_access', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 6, + 'MixedCase@Permanent.org', + 1, + 1, + 'token-6', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 7, + 'newaccount@permanent.org', + 1, + 1, + 'token-7', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO invite_share ( + invite_shareid, + inviteid, + folder_linkid, + accessrole, + status, + type, + createddt, + updateddt +) VALUES +( + 1, + 1, + 1, + 'access.role.viewer', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 2, + 2, + 1, + 'access.role.editor', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 3, + 3, + NULL, + NULL, + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 4, + 4, + 3, + 'access.role.viewer', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 5, + 6, + 1, + 'access.role.bogus', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 6, + 7, + 1, + 'access.role.editor', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); diff --git a/packages/api/src/invite/fixtures/create_test_records.sql b/packages/api/src/invite/fixtures/create_test_records.sql new file mode 100644 index 00000000..ce5119de --- /dev/null +++ b/packages/api/src/invite/fixtures/create_test_records.sql @@ -0,0 +1,19 @@ +INSERT INTO record ( + recordid, + archiveid, + displayname, + uploadfilename, + downloadname, + uploadaccountid, + status, + type +) VALUES ( + 1, + 1, + 'Shared Record', + 'shared-record.jpg', + 'shared-record.jpg', + 1, + 'status.generic.ok', + 'type.record.image' +); diff --git a/packages/api/src/invite/models.ts b/packages/api/src/invite/models.ts new file mode 100644 index 00000000..58cf5bcf --- /dev/null +++ b/packages/api/src/invite/models.ts @@ -0,0 +1,8 @@ +import type { AccessRole } from "../access/models.js"; + +export interface PendingInviteShare { + inviteId: string; + inviteShareId: string; + folderLinkId: string | null; + accessRole: AccessRole | null; +} diff --git a/packages/api/src/invite/queries/create_access_grants_recursive.sql b/packages/api/src/invite/queries/create_access_grants_recursive.sql new file mode 100644 index 00000000..3ad7488b --- /dev/null +++ b/packages/api/src/invite/queries/create_access_grants_recursive.sql @@ -0,0 +1,57 @@ +WITH RECURSIVE grant_params AS ( + SELECT + :archiveId::bigint AS archiveid, + :folderLinkIds::bigint[] AS folder_linkids, + :accessRoles::text[] AS accessroles +), + +shared_grant AS ( + SELECT + grant_params.archiveid, + shared.folder_linkid, + shared.accessrole + FROM grant_params + CROSS JOIN + UNNEST(grant_params.folder_linkids, grant_params.accessroles) + AS shared (folder_linkid, accessrole) +), + +shared_subtree AS ( + SELECT + shared_grant.archiveid, + folder_link.folder_linkid, + shared_grant.accessrole + FROM shared_grant + INNER JOIN folder_link + ON shared_grant.folder_linkid = folder_link.folder_linkid + + UNION ALL + + SELECT + shared_subtree.archiveid, + folder_link.folder_linkid, + shared_subtree.accessrole + FROM folder_link + INNER JOIN shared_subtree + ON folder_link.parentfolder_linkid = shared_subtree.folder_linkid + WHERE folder_link.status != 'status.generic.deleted' +) + +INSERT INTO access ( + folder_linkid, + archiveid, + accessrole, + status, + type, + createddt, + updateddt +) +SELECT + shared_subtree.folder_linkid, + shared_subtree.archiveid, + shared_subtree.accessrole, + 'status.generic.ok' AS status, + 'type.access.share' AS type, + CURRENT_TIMESTAMP AS createddt, + CURRENT_TIMESTAMP AS updateddt +FROM shared_subtree; diff --git a/packages/api/src/invite/queries/create_share.sql b/packages/api/src/invite/queries/create_share.sql new file mode 100644 index 00000000..7666ed36 --- /dev/null +++ b/packages/api/src/invite/queries/create_share.sql @@ -0,0 +1,48 @@ +WITH share_params AS ( + SELECT + :archiveId::bigint AS archiveid, + :folderLinkIds::bigint[] AS folder_linkids, + :accessRoles::text[] AS accessroles +), + +new_share AS ( + SELECT + share_params.archiveid, + shared.folder_linkid, + shared.accessrole + FROM share_params + CROSS JOIN + UNNEST(share_params.folder_linkids, share_params.accessroles) + AS shared (folder_linkid, accessrole) +) + +INSERT INTO share ( + folder_linkid, + archiveid, + accessrole, + status, + type, + createddt, + updateddt +) +SELECT + new_share.folder_linkid, + new_share.archiveid, + new_share.accessrole, + 'status.generic.ok' AS status, + CASE + WHEN folder_link.recordid IS NULL THEN 'type.share.folder' + ELSE 'type.share.record' + END AS type, + CURRENT_TIMESTAMP AS createddt, + CURRENT_TIMESTAMP AS updateddt +FROM new_share +INNER JOIN folder_link + ON new_share.folder_linkid = folder_link.folder_linkid +WHERE NOT EXISTS ( + SELECT existing_share.shareid + FROM share AS existing_share + WHERE + existing_share.folder_linkid = new_share.folder_linkid + AND existing_share.archiveid = new_share.archiveid +); diff --git a/packages/api/src/invite/queries/get_pending_invites_by_email.sql b/packages/api/src/invite/queries/get_pending_invites_by_email.sql new file mode 100644 index 00000000..a0433547 --- /dev/null +++ b/packages/api/src/invite/queries/get_pending_invites_by_email.sql @@ -0,0 +1,13 @@ +SELECT + invite.inviteid AS "inviteId", + invite_share.invite_shareid AS "inviteShareId", + invite_share.folder_linkid AS "folderLinkId", + invite_share.accessrole AS "accessRole" +FROM invite +INNER JOIN invite_share + ON invite.inviteid = invite_share.inviteid +WHERE + LOWER(invite.email) = LOWER(:email) + AND invite.status = 'status.invite.pending' + AND invite.type = 'type.invite.share' + AND invite_share.status = 'status.invite.pending'; diff --git a/packages/api/src/invite/queries/update_invite_share_status.sql b/packages/api/src/invite/queries/update_invite_share_status.sql new file mode 100644 index 00000000..37f00e79 --- /dev/null +++ b/packages/api/src/invite/queries/update_invite_share_status.sql @@ -0,0 +1,3 @@ +UPDATE invite_share +SET status = :status, updateddt = CURRENT_TIMESTAMP +WHERE invite_shareid = ANY(:inviteShareIds::bigint[]); diff --git a/packages/api/src/invite/queries/update_invite_status.sql b/packages/api/src/invite/queries/update_invite_status.sql new file mode 100644 index 00000000..562fdd49 --- /dev/null +++ b/packages/api/src/invite/queries/update_invite_status.sql @@ -0,0 +1,3 @@ +UPDATE invite +SET status = :status, updateddt = CURRENT_TIMESTAMP +WHERE inviteid = ANY(:inviteIds::bigint[]); diff --git a/packages/api/src/invite/service.test.ts b/packages/api/src/invite/service.test.ts new file mode 100644 index 00000000..65f93c8a --- /dev/null +++ b/packages/api/src/invite/service.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { db } from "../database.js"; +import { runFixtures } from "../../test/run_fixtures.js"; +import { mockSqlCall } from "../../test/mock_sql.js"; +import { processPendingInvites } from "./service.js"; + +vi.mock("../database"); + +const loadFixtures = async (): Promise => { + await runFixtures(db, [ + "invite.fixtures.create_test_accounts", + "invite.fixtures.create_test_archives", + "invite.fixtures.create_test_folders", + "invite.fixtures.create_test_records", + "invite.fixtures.create_test_folder_links", + "invite.fixtures.create_test_invites", + ]); +}; + +const clearDatabase = async (): Promise => { + await db.query( + "TRUNCATE account, archive, folder, record, folder_link, invite, invite_share, share, access CASCADE", + ); +}; + +describe("processPendingInvites", () => { + beforeEach(async () => { + await loadFixtures(); + }); + + afterEach(async () => { + await clearDatabase(); + }); + + test("should create a share row for the pending invite share", async () => { + await processPendingInvites("newaccount@permanent.org", "2", db); + + const result = await db.query<{ + folder_linkid: string; + archiveid: string; + accessrole: string; + status: string; + type: string; + }>("SELECT folder_linkid, archiveid, accessrole, status, type FROM share"); + expect(result.rows).toEqual([ + { + folder_linkid: "1", + archiveid: "2", + accessrole: "access.role.editor", + status: "status.generic.ok", + type: "type.share.folder", + }, + ]); + }); + + test("should grant recursive access to the shared folder_link and its descendants", async () => { + await processPendingInvites("newaccount@permanent.org", "2", db); + + const result = await db.query<{ + folder_linkid: string; + archiveid: string; + accessrole: string; + }>( + "SELECT folder_linkid, archiveid, accessrole FROM access ORDER BY folder_linkid", + ); + expect(result.rows).toEqual([ + { folder_linkid: "1", archiveid: "2", accessrole: "access.role.editor" }, + { folder_linkid: "2", archiveid: "2", accessrole: "access.role.editor" }, + ]); + }); + + test("should return the ids of the invites it accepted", async () => { + const acceptedInviteIds = await processPendingInvites( + "newaccount@permanent.org", + "2", + db, + ); + + expect(acceptedInviteIds).toEqual(["1", "7"]); + }); + + test("should use the most permissive access role when two invites name the same folder_link", async () => { + await processPendingInvites("newaccount@permanent.org", "2", db); + + const shareResult = await db.query( + "SELECT * FROM share WHERE folder_linkid = 1", + ); + expect(shareResult.rows).toHaveLength(1); + + const accessResult = await db.query<{ accessRole: string }>( + 'SELECT accessrole AS "accessRole" FROM access WHERE folder_linkid = 1', + ); + expect(accessResult.rows).toHaveLength(1); + expect(accessResult.rows[0]?.accessRole).toEqual("access.role.editor"); + + const inviteResult = await db.query<{ status: string }>( + "SELECT status FROM invite WHERE inviteid = 7", + ); + expect(inviteResult.rows[0]?.status).toEqual("status.invite.accepted"); + }); + + test("should not consume a pending invite of a different type", async () => { + await processPendingInvites("newaccount@permanent.org", "2", db); + + const inviteResult = await db.query<{ status: string }>( + "SELECT status FROM invite WHERE inviteid = 5", + ); + expect(inviteResult.rows[0]?.status).toEqual("status.invite.pending"); + }); + + test("should match the invite email case-insensitively", async () => { + const acceptedInviteIds = await processPendingInvites( + "mixedCASE@permanent.org", + "2", + db, + ); + + expect(acceptedInviteIds).toEqual(["6"]); + const inviteResult = await db.query<{ status: string }>( + "SELECT status FROM invite WHERE inviteid = 6", + ); + expect(inviteResult.rows[0]?.status).toEqual("status.invite.accepted"); + }); + + test("should accept an invite_share with an unrecognized access role without granting anything", async () => { + await processPendingInvites("mixedcase@permanent.org", "2", db); + + const inviteShareResult = await db.query<{ status: string }>( + "SELECT status FROM invite_share WHERE invite_shareid = 5", + ); + expect(inviteShareResult.rows[0]?.status).toEqual("status.invite.accepted"); + + const shareResult = await db.query("SELECT * FROM share"); + expect(shareResult.rows).toHaveLength(0); + + const accessResult = await db.query("SELECT * FROM access"); + expect(accessResult.rows).toHaveLength(0); + }); + + test("should mark the invite_share and invite accepted", async () => { + await processPendingInvites("newaccount@permanent.org", "2", db); + + const inviteShareResult = await db.query<{ status: string }>( + "SELECT status FROM invite_share WHERE invite_shareid = 1", + ); + expect(inviteShareResult.rows[0]?.status).toEqual("status.invite.accepted"); + + const inviteResult = await db.query<{ status: string }>( + "SELECT status FROM invite WHERE inviteid = 1", + ); + expect(inviteResult.rows[0]?.status).toEqual("status.invite.accepted"); + }); + + test("should not touch a revoked invite", async () => { + await processPendingInvites("newaccount@permanent.org", "2", db); + + const inviteResult = await db.query<{ status: string }>( + "SELECT status FROM invite WHERE inviteid = 2", + ); + expect(inviteResult.rows[0]?.status).toEqual("status.invite.revoked"); + }); + + test("should do nothing for an email with no pending invites", async () => { + const acceptedInviteIds = await processPendingInvites( + "nobody@permanent.org", + "2", + db, + ); + + expect(acceptedInviteIds).toEqual([]); + const shareResult = await db.query("SELECT * FROM share"); + expect(shareResult.rows).toHaveLength(0); + }); + + test("should mark an invite_share with NULL folder_linkid/accessrole as accepted without creating a share or access grant", async () => { + await processPendingInvites("nullfields@permanent.org", "2", db); + + const inviteShareResult = await db.query<{ status: string }>( + "SELECT status FROM invite_share WHERE invite_shareid = 3", + ); + expect(inviteShareResult.rows[0]?.status).toEqual("status.invite.accepted"); + + const inviteResult = await db.query<{ status: string }>( + "SELECT status FROM invite WHERE inviteid = 3", + ); + expect(inviteResult.rows[0]?.status).toEqual("status.invite.accepted"); + + const shareResult = await db.query("SELECT * FROM share"); + expect(shareResult.rows).toHaveLength(0); + + const accessResult = await db.query("SELECT * FROM access"); + expect(accessResult.rows).toHaveLength(0); + }); + + test("should create a record-typed share for a record-backed folder_link", async () => { + await processPendingInvites("recordshare@permanent.org", "2", db); + + const result = await db.query<{ type: string }>( + "SELECT type FROM share WHERE folder_linkid = 3", + ); + expect(result.rows[0]?.type).toEqual("type.share.record"); + }); + + test("should throw if looking up pending invites fails", async () => { + mockSqlCall( + db, + "invite.queries.get_pending_invites_by_email", + { email: "newaccount@permanent.org" }, + { reject: new Error("test error") }, + ); + + await expect( + processPendingInvites("newaccount@permanent.org", "2", db), + ).rejects.toThrow("Failed to look up pending invites"); + }); + + test("should throw if creating the share fails", async () => { + mockSqlCall( + db, + "invite.queries.create_share", + { + folderLinkIds: ["1"], + accessRoles: ["access.role.editor"], + archiveId: "2", + }, + { reject: new Error("test error") }, + ); + + await expect( + processPendingInvites("newaccount@permanent.org", "2", db), + ).rejects.toThrow("Failed to create share"); + }); + + test("should throw if granting recursive access fails", async () => { + mockSqlCall( + db, + "invite.queries.create_access_grants_recursive", + { + folderLinkIds: ["1"], + accessRoles: ["access.role.editor"], + archiveId: "2", + }, + { reject: new Error("test error") }, + ); + + await expect( + processPendingInvites("newaccount@permanent.org", "2", db), + ).rejects.toThrow("Failed to grant access to shared folder"); + }); + + test("should throw if updating invite_share status fails", async () => { + mockSqlCall( + db, + "invite.queries.update_invite_share_status", + { inviteShareIds: ["1", "6"], status: "status.invite.accepted" }, + { reject: new Error("test error") }, + ); + + await expect( + processPendingInvites("newaccount@permanent.org", "2", db), + ).rejects.toThrow("Failed to update invite share status"); + }); + + test("should throw if updating invite status fails", async () => { + mockSqlCall( + db, + "invite.queries.update_invite_status", + { inviteIds: ["1", "7"], status: "status.invite.accepted" }, + { reject: new Error("test error") }, + ); + + await expect( + processPendingInvites("newaccount@permanent.org", "2", db), + ).rejects.toThrow("Failed to update invite status"); + }); +}); diff --git a/packages/api/src/invite/service.ts b/packages/api/src/invite/service.ts new file mode 100644 index 00000000..14123e0f --- /dev/null +++ b/packages/api/src/invite/service.ts @@ -0,0 +1,115 @@ +import createError from "http-errors"; +import { logger } from "@stela/logger"; +import type { TinyPg } from "tinypg"; +import { AccessRole } from "../access/models.js"; +import type { PendingInviteShare } from "./models.js"; +import { accessRoleLessThan } from "../access/permission.js"; + +const ACCEPTED_STATUS = "status.invite.accepted"; + +const VALID_ACCESS_ROLES = new Set(Object.values(AccessRole)); + +export const processPendingInvites = async ( + email: string, + archiveId: string, + db: TinyPg, +): Promise => { + const pendingInvitesResult = await db + .sql("invite.queries.get_pending_invites_by_email", { + email, + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError( + "Failed to look up pending invites", + ); + }); + + const { rows: pendingInvites } = pendingInvitesResult; + const inviteIds = pendingInvites.map((invite) => invite.inviteId); + if (inviteIds.length === 0) { + return []; + } + const accessRoleByFolderLinkId = new Map(); + pendingInvites.forEach((inviteShare) => { + const { folderLinkId, accessRole } = inviteShare; + if (folderLinkId === null || accessRole === null) { + return; + } + if (!VALID_ACCESS_ROLES.has(accessRole)) { + logger.error( + `Skipping invite_share ${inviteShare.inviteShareId}: unrecognized access role "${accessRole}"`, + ); + return; + } + + const folderLinkIdCurrentAccessRole = + accessRoleByFolderLinkId.get(folderLinkId); + if ( + folderLinkIdCurrentAccessRole === undefined || + accessRoleLessThan(folderLinkIdCurrentAccessRole, accessRole) + ) { + accessRoleByFolderLinkId.set(folderLinkId, accessRole); + } + }); + + if (accessRoleByFolderLinkId.size > 0) { + const folderLinkIds = [...accessRoleByFolderLinkId.keys()]; + const accessRoles = [...accessRoleByFolderLinkId.values()]; + + await db + .sql("invite.queries.create_share", { + folderLinkIds, + accessRoles, + archiveId, + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError("Failed to create share"); + }); + + await db + .sql("invite.queries.create_access_grants_recursive", { + folderLinkIds, + accessRoles, + archiveId, + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError( + "Failed to grant access to shared folder", + ); + }); + } + + const inviteShareIds = pendingInvites.map( + (inviteShare) => inviteShare.inviteShareId, + ); + if (inviteShareIds.length > 0) { + await db + .sql("invite.queries.update_invite_share_status", { + inviteShareIds, + status: ACCEPTED_STATUS, + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError( + "Failed to update invite share status", + ); + }); + } + + await db + .sql("invite.queries.update_invite_status", { + inviteIds, + status: ACCEPTED_STATUS, + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError( + "Failed to update invite status", + ); + }); + + return inviteIds; +}; From eb51e0f0a475bb366f5dfd44df3cada6f240037b Mon Sep 17 00:00:00 2001 From: Liam Lloyd-Tucker Date: Tue, 15 Sep 2026 15:51:04 -0700 Subject: [PATCH 2/2] Add POST /archives endpoint This commit migrates our archive creation endpoint to stela, in preparation for the 2030 dashboard which we want to build exclusively with stela endpoints. Some functionality changes worthy of note: - This POST /archives does not trigger generation of a default archive profile image. We've determined that going forward it will be a client-side responsibility to decide what to display when the user has not set a profile image. - This POST /archives does not act on Relationship invitations; we intend to remove and replace the relationships feature entirely. --- packages/api/docs/src/paths/archive.yaml | 15 +- .../api/src/archive/controller/controller.ts | 25 ++ .../archive/controller/create_archive.test.ts | 410 ++++++++++++++++++ ...reate_test_accounts_for_create_archive.sql | 17 + .../create_test_accounts_for_post.sql | 27 ++ .../create_test_pending_share_invite.sql | 95 ++++ packages/api/src/archive/models.ts | 17 + .../queries/acquire_create_archive_lock.sql | 1 + .../queries/create_account_archive.sql | 25 ++ .../src/archive/queries/create_archive.sql | 76 ++++ .../queries/create_initial_profile_item.sql | 33 ++ .../query_tests/create_archive_query.test.ts | 75 ++++ .../api/src/archive/service/create_archive.ts | 111 +++++ .../src/archive/service/get_shared_folders.ts | 2 +- packages/api/src/archive/service/index.ts | 2 + packages/api/src/archive/validators.test.ts | 62 ++- packages/api/src/archive/validators.ts | 30 +- ...reate_test_share_invitation_acceptance.sql | 219 ++++++++++ packages/api/src/email/index.ts | 2 + ...et_share_invitation_acceptance_details.sql | 28 ++ packages/api/src/email/service.test.ts | 104 +++++ packages/api/src/email/service.ts | 53 +++ .../api/src/folder/controller/controller.ts | 23 +- .../api/src/folder/queries/create_folder.sql | 85 ++++ .../service/create_default_folders.test.ts | 115 +++++ .../folder/service/create_default_folders.ts | 131 ++++++ .../get_folder.test.ts} | 4 +- .../{service.ts => service/get_folder.ts} | 137 +----- .../folder/service/get_folder_share_links.ts | 31 ++ packages/api/src/folder/service/index.ts | 13 + .../api/src/folder/service/update_folder.ts | 96 ++++ packages/api/src/record/service.ts | 2 +- 32 files changed, 1918 insertions(+), 148 deletions(-) create mode 100644 packages/api/src/archive/controller/create_archive.test.ts create mode 100644 packages/api/src/archive/fixtures/create_test_accounts_for_create_archive.sql create mode 100644 packages/api/src/archive/fixtures/create_test_accounts_for_post.sql create mode 100644 packages/api/src/archive/fixtures/create_test_pending_share_invite.sql create mode 100644 packages/api/src/archive/queries/acquire_create_archive_lock.sql create mode 100644 packages/api/src/archive/queries/create_account_archive.sql create mode 100644 packages/api/src/archive/queries/create_archive.sql create mode 100644 packages/api/src/archive/queries/create_initial_profile_item.sql create mode 100644 packages/api/src/archive/queries/query_tests/create_archive_query.test.ts create mode 100644 packages/api/src/archive/service/create_archive.ts create mode 100644 packages/api/src/email/fixtures/create_test_share_invitation_acceptance.sql create mode 100644 packages/api/src/email/queries/get_share_invitation_acceptance_details.sql create mode 100644 packages/api/src/folder/queries/create_folder.sql create mode 100644 packages/api/src/folder/service/create_default_folders.test.ts create mode 100644 packages/api/src/folder/service/create_default_folders.ts rename packages/api/src/folder/{service.test.ts => service/get_folder.test.ts} (98%) rename packages/api/src/folder/{service.ts => service/get_folder.ts} (66%) create mode 100644 packages/api/src/folder/service/get_folder_share_links.ts create mode 100644 packages/api/src/folder/service/index.ts create mode 100644 packages/api/src/folder/service/update_folder.ts diff --git a/packages/api/docs/src/paths/archive.yaml b/packages/api/docs/src/paths/archive.yaml index 102e32ea..a53622e3 100644 --- a/packages/api/docs/src/paths/archive.yaml +++ b/packages/api/docs/src/paths/archive.yaml @@ -73,18 +73,27 @@ archives: - bearerHttpAuthentication: [] tags: - archives - - unimplemented operationId: post-archives requestBody: + required: true content: application/json: schema: type: object + required: + - name properties: name: type: string type: - $ref: "../models/archive_type.yaml#/archiveType" + description: >- + Defaults to `person` if omitted. + type: string + default: person + enum: + - person + - group + - organization responses: "200": description: The created archive @@ -97,6 +106,8 @@ archives: $ref: "../models/archive.yaml#/archive" "400": $ref: "../errors.yaml#/400" + "401": + $ref: "../errors.yaml#/401" "500": $ref: "../errors.yaml#/500" archives/{id}: diff --git a/packages/api/src/archive/controller/controller.ts b/packages/api/src/archive/controller/controller.ts index a5f30d2c..154feae6 100644 --- a/packages/api/src/archive/controller/controller.ts +++ b/packages/api/src/archive/controller/controller.ts @@ -6,6 +6,7 @@ import { verifyAdminAuthentication, extractUserIsAdminFromAuthToken, extractUserEmailFromAuthToken, + extractIp, } from "../../middleware/index.js"; import { validateArchiveIdFromParams, @@ -13,12 +14,36 @@ import { validateSearchQuery, validatePatchArchiveBody, validateGetSharedFoldersQuery, + validateCreateArchiveRequest, } from "../validators.js"; import { archiveService } from "../service/index.js"; import { validatePaginationParameters } from "../../validators/shared.js"; +import { ArchiveType } from "../models.js"; export const archiveController = Router(); +archiveController.post( + "/", + verifyUserAuthentication, + extractIp, + async (req: Request, res: Response, next: NextFunction) => { + try { + validateCreateArchiveRequest(req.body); + const archive = await archiveService.createArchive({ + emailFromAuthToken: req.body.emailFromAuthToken, + userSubjectFromAuthToken: req.body.userSubjectFromAuthToken, + ip: req.body.ip, + userAgent: req.get("User-Agent"), + name: req.body.name, + type: req.body.type ?? ArchiveType.Person, + }); + res.status(HTTP_STATUS.SUCCESSFUL.CREATED).json({ data: archive }); + } catch (err) { + next(err); + } + }, +); + archiveController.get( "/", extractUserIsAdminFromAuthToken, diff --git a/packages/api/src/archive/controller/create_archive.test.ts b/packages/api/src/archive/controller/create_archive.test.ts new file mode 100644 index 00000000..64ddec35 --- /dev/null +++ b/packages/api/src/archive/controller/create_archive.test.ts @@ -0,0 +1,410 @@ +import type { MessagesSendSuccessResponse } from "@mailchimp/mailchimp_transactional"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { app } from "../../app.js"; +import { db } from "../../database.js"; +import { MailchimpTransactional } from "../../mailchimp.js"; +import { + mockVerifyUserAuthentication, + mockExtractIp, +} from "../../../test/middleware_mocks.js"; +import { runFixtures } from "../../../test/run_fixtures.js"; +import { mockSqlCall } from "../../../test/mock_sql.js"; +import type { Archive } from "../models.js"; + +vi.mock("../../database"); +vi.mock("../../middleware"); +vi.mock("../../mailchimp"); + +const clearDatabase = async (): Promise => { + await db.query( + "TRUNCATE account, archive, archive_nbr, account_archive, folder, folder_link, profile_item, invite, invite_share, share, access, event RESTART IDENTITY CASCADE", + ); +}; + +describe("POST /archives", () => { + const agent = request(app); + + beforeEach(async () => { + mockVerifyUserAuthentication( + "newarchiveowner@permanent.org", + "82bd483e-914b-4bfe-abf9-92ffe86d7803", + ); + mockExtractIp("127.0.0.1"); + const mockResponse: MessagesSendSuccessResponse[] = [ + { + email: "someone@permanent.org", + status: "sent", + _id: "test-id", + reject_reason: null, + }, + ]; + vi.mocked(MailchimpTransactional.messages.sendTemplate).mockResolvedValue( + mockResponse, + ); + await runFixtures(db, ["archive.fixtures.create_test_accounts_for_post"]); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await clearDatabase(); + }); + + test.each(["person", "group", "organization"] as const)( + "should create a %s archive and return it", + async (type) => { + const response = await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type }) + .expect(201); + + const { + body: { data: archive }, + } = response as { body: { data: Archive } }; + expect(archive.name).toEqual("My Archive"); + expect(archive.type).toEqual(type); + expect(archive.public).toEqual(false); + expect(archive.allowPublicDownload).toEqual(true); + expect(archive.status).toEqual("ok"); + expect(archive.rootFolderId).toBeDefined(); + }, + ); + + test("should default to person if type is omitted", async () => { + const response = await agent + .post("/api/v2/archives") + .send({ name: "My Archive" }) + .expect(201); + + const { + body: { data: archive }, + } = response as { body: { data: Archive } }; + expect(archive.type).toEqual("person"); + }); + + test("should create the owner account_archive link", async () => { + const response = await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(201); + + const { + body: { data: archive }, + } = response as { body: { data: Archive } }; + const result = await db.query<{ accessrole: string }>( + "SELECT accessrole FROM account_archive WHERE archiveid = :archiveId", + { archiveId: archive.archiveId }, + ); + expect(result.rows).toEqual([{ accessrole: "access.role.owner" }]); + }); + + test("should create 4 default folders", async () => { + const response = await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(201); + + const { + body: { data: archive }, + } = response as { body: { data: Archive } }; + const result = await db.query( + "SELECT * FROM folder WHERE archiveid = :archiveId", + { archiveId: archive.archiveId }, + ); + expect(result.rows).toHaveLength(4); + }); + + test("should create an archive.create event", async () => { + const response = await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(201); + + const { + body: { data: archive }, + } = response as { body: { data: Archive } }; + const result = await db.query<{ entity: string; action: string }>( + "SELECT entity, action FROM event WHERE entity_id = :archiveId", + { archiveId: archive.archiveId }, + ); + expect(result.rows).toEqual([{ entity: "archive", action: "create" }]); + }); + + test("should not send the acceptance email when there is no share invite", async () => { + await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(201); + + expect(MailchimpTransactional.messages.sendTemplate).not.toHaveBeenCalled(); + }); + + test("should return 400 if name is missing", async () => { + await agent.post("/api/v2/archives").send({}).expect(400); + }); + + test("should return 400 if name is an empty string", async () => { + await agent.post("/api/v2/archives").send({ name: "" }).expect(400); + }); + + test("should return 400 if name is only whitespace", async () => { + await agent.post("/api/v2/archives").send({ name: " " }).expect(400); + }); + + test("should return 400 if type is nonprofit", async () => { + await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "nonprofit" }) + .expect(400); + }); + + test("should return 500 if the authenticated email has no account", async () => { + mockVerifyUserAuthentication( + "noaccount@permanent.org", + "5d1bc603-a721-4127-9c74-3a8ba4704ec5", + ); + + await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(500); + + const archiveResult = await db.query("SELECT * FROM archive"); + expect(archiveResult.rows).toHaveLength(0); + }); + + test("should return 401 if not authenticated", async () => { + const { verifyUserAuthentication } = + await import("../../middleware/index.js"); + vi.mocked(verifyUserAuthentication).mockImplementation( + async (_req, _res, next) => { + const { default: createError } = await import("http-errors"); + next(new createError.Unauthorized("Invalid token")); + }, + ); + await agent + .post("/api/v2/archives") + .send({ name: "My Archive" }) + .expect(401); + }); + + describe("with a pending share invite", () => { + beforeEach(async () => { + await runFixtures(db, [ + "archive.fixtures.create_test_pending_share_invite", + ]); + }); + + test("should create a share, grant access, and send the acceptance email", async () => { + const response = await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(201); + + const { + body: { data: archive }, + } = response as { body: { data: Archive } }; + + const shareResult = await db.query<{ archiveid: string }>( + "SELECT archiveid FROM share WHERE folder_linkid = 500", + ); + expect(shareResult.rows).toEqual([{ archiveid: archive.archiveId }]); + + const accessResult = await db.query<{ archiveid: string }>( + "SELECT archiveid FROM access WHERE folder_linkid = 500", + ); + expect(accessResult.rows).toEqual([{ archiveid: archive.archiveId }]); + + const inviteResult = await db.query<{ status: string }>( + "SELECT status FROM invite WHERE inviteid = 500", + ); + expect(inviteResult.rows[0]?.status).toEqual("status.invite.accepted"); + + expect(MailchimpTransactional.messages.sendTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + template_name: "share-invitation-acceptance", + }), + ); + }); + + test("should still return 201 if sending the acceptance email fails", async () => { + vi.mocked(MailchimpTransactional.messages.sendTemplate).mockRejectedValue( + new Error("mailchimp is down"), + ); + + const response = await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(201); + + const { + body: { data: archive }, + } = response as { body: { data: Archive } }; + expect(archive.name).toEqual("My Archive"); + }); + }); + + test("should return 500 and roll back everything if the event insert fails", async () => { + mockSqlCall( + db, + "event.queries.create_event", + { + entity: "archive", + action: "create", + version: 1, + actorType: "user", + actorId: "82bd483e-914b-4bfe-abf9-92ffe86d7803", + entityId: "1", + ip: "127.0.0.1", + userAgent: undefined, + body: { name: "My Archive", type: "person" }, + }, + { reject: new Error("test error") }, + ); + + await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(500); + + const archiveResult = await db.query("SELECT * FROM archive"); + expect(archiveResult.rows).toHaveLength(0); + const folderResult = await db.query("SELECT * FROM folder"); + expect(folderResult.rows).toHaveLength(0); + const profileItemResult = await db.query("SELECT * FROM profile_item"); + expect(profileItemResult.rows).toHaveLength(0); + const accountArchiveResult = await db.query( + "SELECT * FROM account_archive", + ); + expect(accountArchiveResult.rows).toHaveLength(0); + }); + + test("should return 500 if the archive creation query fails", async () => { + mockSqlCall( + db, + "archive.queries.create_archive", + { archiveType: "type.archive.person" }, + { reject: new Error("test error") }, + ); + + await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(500); + + const archiveResult = await db.query("SELECT * FROM archive"); + expect(archiveResult.rows).toHaveLength(0); + }); + + test("should return 500 if the archive creation query returns 0 rows", async () => { + mockSqlCall( + db, + "archive.queries.create_archive", + { archiveType: "type.archive.person" }, + { resolve: { rows: [] } }, + ); + + await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(500); + + const archiveResult = await db.query("SELECT * FROM archive"); + expect(archiveResult.rows).toHaveLength(0); + }); + + test("should return 500 and roll back everything if the initial profile item query fails", async () => { + mockSqlCall( + db, + "archive.queries.create_initial_profile_item", + { archiveId: "1", name: "My Archive" }, + { reject: new Error("test error") }, + ); + + await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(500); + + const archiveResult = await db.query("SELECT * FROM archive"); + expect(archiveResult.rows).toHaveLength(0); + const accountArchiveResult = await db.query( + "SELECT * FROM account_archive", + ); + expect(accountArchiveResult.rows).toHaveLength(0); + }); + + test("should return 500 and roll back everything if creating a default folder fails", async () => { + mockSqlCall( + db, + "folder.queries.create_folder", + { + archiveId: "1", + archiveNbr: "0000-0001", + archivePart: "0000", + itemPart: "0001", + displayName: "Archive Root", + downloadName: "Archive Root", + description: null, + type: "type.folder.root.root", + publicDt: null, + parentFolderId: null, + parentFolderLinkId: null, + position: 1, + folderLinkType: "type.folder_link.root.root", + }, + { reject: new Error("test error") }, + ); + + await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(500); + + const archiveResult = await db.query("SELECT * FROM archive"); + expect(archiveResult.rows).toHaveLength(0); + const folderResult = await db.query("SELECT * FROM folder"); + expect(folderResult.rows).toHaveLength(0); + }); + + test("should return 500 and roll back everything if processing pending invites fails", async () => { + mockSqlCall( + db, + "invite.queries.get_pending_invites_by_email", + { email: "newarchiveowner@permanent.org" }, + { reject: new Error("test error") }, + ); + + await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(500); + + const archiveResult = await db.query("SELECT * FROM archive"); + expect(archiveResult.rows).toHaveLength(0); + const folderResult = await db.query("SELECT * FROM folder"); + expect(folderResult.rows).toHaveLength(0); + }); + + test("should return 500 if fetching the created archive fails after commit, even though it was already created", async () => { + mockSqlCall( + db, + "archive.queries.get_archives", + { + archiveIds: ["1"], + accountEmail: "newarchiveowner@permanent.org", + pageSize: 1, + cursor: undefined, + }, + { reject: new Error("test error") }, + ); + + await agent + .post("/api/v2/archives") + .send({ name: "My Archive", type: "person" }) + .expect(500); + + const archiveResult = await db.query("SELECT * FROM archive"); + expect(archiveResult.rows).toHaveLength(1); + }); +}); diff --git a/packages/api/src/archive/fixtures/create_test_accounts_for_create_archive.sql b/packages/api/src/archive/fixtures/create_test_accounts_for_create_archive.sql new file mode 100644 index 00000000..01185df2 --- /dev/null +++ b/packages/api/src/archive/fixtures/create_test_accounts_for_create_archive.sql @@ -0,0 +1,17 @@ +INSERT INTO account ( + accountid, + primaryemail, + status, + notificationpreferences, + type, + fullname, + subject +) VALUES ( + 1, + 'owner@permanent.org', + 'status.auth.ok', + '{}', + 'type.account.standard', + 'Owner Account', + '4f527062-720b-42d6-8596-b42bea94a1a7' +); diff --git a/packages/api/src/archive/fixtures/create_test_accounts_for_post.sql b/packages/api/src/archive/fixtures/create_test_accounts_for_post.sql new file mode 100644 index 00000000..8738c198 --- /dev/null +++ b/packages/api/src/archive/fixtures/create_test_accounts_for_post.sql @@ -0,0 +1,27 @@ +INSERT INTO account ( + accountid, + primaryemail, + status, + notificationpreferences, + type, + fullname, + subject +) VALUES +( + 1, + 'newarchiveowner@permanent.org', + 'status.auth.ok', + '{}', + 'type.account.standard', + 'New Owner', + '82bd483e-914b-4bfe-abf9-92ffe86d7803' +), +( + 2, + 'inviter@permanent.org', + 'status.auth.ok', + '{}', + 'type.account.standard', + 'Inviter Fullname', + '015b36f4-d60d-4b8d-8750-6ae2bdd12bc5' +); diff --git a/packages/api/src/archive/fixtures/create_test_pending_share_invite.sql b/packages/api/src/archive/fixtures/create_test_pending_share_invite.sql new file mode 100644 index 00000000..63a5fe65 --- /dev/null +++ b/packages/api/src/archive/fixtures/create_test_pending_share_invite.sql @@ -0,0 +1,95 @@ +INSERT INTO archive ( + archiveid, archivenbr, public, type, status, createddt, updateddt +) VALUES ( + 500, + '0500-0000', + FALSE, + 'type.archive.person', + 'status.generic.ok', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO folder ( + folderid, + archiveid, + displayname, + downloadname, + status, + type, + createddt, + updateddt +) VALUES ( + 500, + 500, + 'Shared Folder', + 'Shared Folder', + 'status.generic.ok', + 'type.folder.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO folder_link ( + folder_linkid, + folderid, + archiveid, + position, + accessrole, + status, + type, + createddt, + updateddt +) VALUES ( + 500, + 500, + 500, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO invite ( + inviteid, + email, + byarchiveid, + byaccountid, + token, + status, + type, + createddt, + updateddt +) VALUES ( + 500, + 'newarchiveowner@permanent.org', + 500, + 2, + 'token-500', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO invite_share ( + invite_shareid, + inviteid, + folder_linkid, + accessrole, + status, + type, + createddt, + updateddt +) VALUES ( + 500, + 500, + 500, + 'access.role.viewer', + 'status.invite.pending', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); diff --git a/packages/api/src/archive/models.ts b/packages/api/src/archive/models.ts index afc9172f..7b7e2358 100644 --- a/packages/api/src/archive/models.ts +++ b/packages/api/src/archive/models.ts @@ -2,6 +2,15 @@ import { ArchiveMembershipRole } from "../access/models.js"; import type { Folder } from "../folder/models.js"; import type { Share } from "../share/models.js"; +export const ARCHIVE_TYPE_TO_DB_VALUE: Record< + "person" | "group" | "organization", + string +> = { + person: "type.archive.person", + group: "type.archive.family", + organization: "type.archive.organization", +}; + export { ArchiveMembershipRole }; export interface Tag { @@ -124,3 +133,11 @@ export interface GetReceivedSharesResponse { totalPages: number; }; } + +export interface CreateArchiveRequest { + emailFromAuthToken: string; + userSubjectFromAuthToken: string; + name: string; + type?: "person" | "group" | "organization"; + ip: string; +} diff --git a/packages/api/src/archive/queries/acquire_create_archive_lock.sql b/packages/api/src/archive/queries/acquire_create_archive_lock.sql new file mode 100644 index 00000000..43a1ca21 --- /dev/null +++ b/packages/api/src/archive/queries/acquire_create_archive_lock.sql @@ -0,0 +1 @@ +SELECT PG_ADVISORY_XACT_LOCK(HASHTEXT('archive_nbr_archive_part')); diff --git a/packages/api/src/archive/queries/create_account_archive.sql b/packages/api/src/archive/queries/create_account_archive.sql new file mode 100644 index 00000000..20c799c1 --- /dev/null +++ b/packages/api/src/archive/queries/create_account_archive.sql @@ -0,0 +1,25 @@ +INSERT INTO account_archive ( + accountid, + archiveid, + accessrole, + position, + type, + status, + createddt, + updateddt +) VALUES ( + ( + SELECT accountid + FROM + account + WHERE + subject = :accountSubject + ), + :archiveId, + 'access.role.owner', + 0, + 'type.account.standard', + 'status.generic.ok', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); diff --git a/packages/api/src/archive/queries/create_archive.sql b/packages/api/src/archive/queries/create_archive.sql new file mode 100644 index 00000000..9880d07e --- /dev/null +++ b/packages/api/src/archive/queries/create_archive.sql @@ -0,0 +1,76 @@ +WITH next_archive_part AS ( + SELECT + COALESCE(INCREMENT_BASE36(MAX(archive_nbr.archivepart)), '0000') + AS archive_part + FROM archive_nbr +), + +new_archive AS ( + INSERT INTO archive ( + archivenbr, + public, + allowpublicdownload, + imageratio, + status, + type, + createddt, + updateddt + ) + SELECT + (SELECT archive_part FROM next_archive_part) || '-0000' AS archivenbr, + -- These aliases are for readability only (Postgres ignores them); + -- `is_public` avoids sqlfluff's RF04 keyword-as-identifier rule. + FALSE AS is_public, + TRUE AS allowpublicdownload, + 1 AS imageratio, + 'status.generic.ok' AS status, + :archiveType AS type, + CURRENT_TIMESTAMP AS createddt, + CURRENT_TIMESTAMP AS updateddt + RETURNING + archiveid, + archivenbr, + public, + allowpublicdownload, + status, + type +), + +new_archive_nbr AS ( + INSERT INTO archive_nbr ( + archivenbr, + reftable, + refid, + archivepart, + itempart, + status, + type, + createddt, + updateddt + ) + SELECT + new_archive.archivenbr, + 'archive' AS reftable, + new_archive.archiveid AS refid, + ( + SELECT next_archive_part.archive_part FROM next_archive_part + ) AS archivepart, + '0000' AS itempart, + 'status.generic.ok' AS status, + 'type.generic.placeholder' AS type, + CURRENT_TIMESTAMP AS createddt, + CURRENT_TIMESTAMP AS updateddt + FROM new_archive +) + +SELECT + archiveid AS "archiveId", + archivenbr AS "archiveNbr", + public, + allowpublicdownload AS "allowPublicDownload", + status, + type, + ( + SELECT next_archive_part.archive_part FROM next_archive_part + ) AS "archivePart" +FROM new_archive; diff --git a/packages/api/src/archive/queries/create_initial_profile_item.sql b/packages/api/src/archive/queries/create_initial_profile_item.sql new file mode 100644 index 00000000..63465f04 --- /dev/null +++ b/packages/api/src/archive/queries/create_initial_profile_item.sql @@ -0,0 +1,33 @@ +INSERT INTO profile_item ( + archiveid, + fieldnameui, + string1, + otherid1, + status, + type, + publicdt, + createddt, + updateddt +) VALUES +( + :archiveId, + 'profile.basic', + :name, + NULL, + 'status.generic.ok', + 'type.profile_item.basic', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + :archiveId, + 'profile.timezone', + NULL, + 88, + 'status.generic.ok', + 'type.profile_item.timezone', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); diff --git a/packages/api/src/archive/queries/query_tests/create_archive_query.test.ts b/packages/api/src/archive/queries/query_tests/create_archive_query.test.ts new file mode 100644 index 00000000..91eb4ccf --- /dev/null +++ b/packages/api/src/archive/queries/query_tests/create_archive_query.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { db } from "../../../database.js"; + +vi.mock("../../../database"); + +const clearDatabase = async (): Promise => { + await db.query("TRUNCATE archive, archive_nbr CASCADE"); +}; + +describe("archive.queries.create_archive", () => { + afterEach(async () => { + await clearDatabase(); + }); + + test("should create the first archive with archivePart 0000", async () => { + const result = await db.sql<{ archiveId: string; archiveNbr: string }>( + "archive.queries.create_archive", + { archiveType: "type.archive.person" }, + ); + const { rows } = result; + const [archive] = rows; + expect(archive?.archiveNbr).toEqual("0000-0000"); + }); + + test("should increment the archive part for a second archive", async () => { + await db.sql("archive.queries.create_archive", { + archiveType: "type.archive.person", + }); + const result = await db.sql<{ archiveId: string; archiveNbr: string }>( + "archive.queries.create_archive", + { archiveType: "type.archive.organization" }, + ); + const { rows } = result; + const [archive] = rows; + expect(archive?.archiveNbr).toEqual("0001-0000"); + }); + + test("should set defaults matching a freshly created archive", async () => { + const result = await db.sql<{ + public: boolean; + allowPublicDownload: boolean; + status: string; + type: string; + }>("archive.queries.create_archive", { + archiveType: "type.archive.family", + }); + const { rows } = result; + const [archive] = rows; + expect(archive?.public).toEqual(false); + expect(archive?.allowPublicDownload).toEqual(true); + expect(archive?.status).toEqual("status.generic.ok"); + expect(archive?.type).toEqual("type.archive.family"); + }); + + test("should create a matching archive_nbr row", async () => { + const result = await db.sql<{ archiveId: string; archiveNbr: string }>( + "archive.queries.create_archive", + { archiveType: "type.archive.person" }, + ); + const { rows } = result; + const [archive] = rows; + const archiveNbrResult = await db.query<{ + reftable: string; + refid: string; + archivepart: string; + itempart: string; + }>("SELECT reftable, refid, archivepart, itempart FROM archive_nbr"); + const { rows: archiveNbrRows } = archiveNbrResult; + const [archiveNbrRow] = archiveNbrRows; + expect(archiveNbrRow?.reftable).toEqual("archive"); + expect(archiveNbrRow?.refid).toEqual(archive?.archiveId); + expect(archiveNbrRow?.archivepart).toEqual("0000"); + expect(archiveNbrRow?.itempart).toEqual("0000"); + }); +}); diff --git a/packages/api/src/archive/service/create_archive.ts b/packages/api/src/archive/service/create_archive.ts new file mode 100644 index 00000000..7b1c1b28 --- /dev/null +++ b/packages/api/src/archive/service/create_archive.ts @@ -0,0 +1,111 @@ +import createError from "http-errors"; +import { logger } from "@stela/logger"; +import { db } from "../../database.js"; +import { createEventInTransaction } from "../../event/service.js"; +import { createDefaultFolders } from "../../folder/service/create_default_folders.js"; +import { processPendingInvites } from "../../invite/service.js"; +import { sendShareInvitationAcceptanceNotification } from "../../email/index.js"; +import { getArchive } from "./get_archive.js"; +import { + type Archive, + type CreateArchiveRequest, + ARCHIVE_TYPE_TO_DB_VALUE, +} from "../models.js"; + +export const createArchive = async ( + requestData: CreateArchiveRequest & { + userAgent: string | undefined; + type: "person" | "group" | "organization"; + }, +): Promise => { + const { archiveId, acceptedInviteIds } = await db.transaction( + async (transactionDb) => { + await transactionDb.sql("archive.queries.acquire_create_archive_lock"); + const archiveResult = await transactionDb + .sql<{ + archiveId: string; + archiveNbr: string; + archivePart: string; + }>("archive.queries.create_archive", { + archiveType: ARCHIVE_TYPE_TO_DB_VALUE[requestData.type], + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError("Failed to create archive"); + }); + const { rows: archiveRows } = archiveResult; + const [newArchive] = archiveRows; + if (newArchive === undefined) { + throw new createError.InternalServerError("Failed to create archive"); + } + + await transactionDb + .sql("archive.queries.create_account_archive", { + accountSubject: requestData.userSubjectFromAuthToken, + archiveId: newArchive.archiveId, + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError( + "Failed to link account to archive", + ); + }); + + await createDefaultFolders( + newArchive.archiveId, + newArchive.archivePart, + transactionDb, + ); + + await transactionDb + .sql("archive.queries.create_initial_profile_item", { + archiveId: newArchive.archiveId, + name: requestData.name, + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError( + "Failed to create initial profile item", + ); + }); + + const processedInviteIds = await processPendingInvites( + requestData.emailFromAuthToken, + newArchive.archiveId, + transactionDb, + ); + + await createEventInTransaction( + { + userSubjectFromAuthToken: requestData.userSubjectFromAuthToken, + userEmailFromAuthToken: requestData.emailFromAuthToken, + entity: "archive", + action: "create", + version: 1, + entityId: newArchive.archiveId, + ip: requestData.ip, + userAgent: requestData.userAgent, + body: { name: requestData.name, type: requestData.type }, + }, + transactionDb, + ); + + return { + archiveId: newArchive.archiveId, + acceptedInviteIds: processedInviteIds, + }; + }, + ); + + const archive = await getArchive(archiveId, requestData.emailFromAuthToken); + + if (acceptedInviteIds.length > 0) { + await sendShareInvitationAcceptanceNotification(acceptedInviteIds).catch( + (err: unknown) => { + logger.error(err); + }, + ); + } + + return archive; +}; diff --git a/packages/api/src/archive/service/get_shared_folders.ts b/packages/api/src/archive/service/get_shared_folders.ts index c2f00aae..6bd7b296 100644 --- a/packages/api/src/archive/service/get_shared_folders.ts +++ b/packages/api/src/archive/service/get_shared_folders.ts @@ -1,7 +1,7 @@ import { logger } from "@stela/logger"; import createError from "http-errors"; import { db } from "../../database.js"; -import { getFolders } from "../../folder/service.js"; +import { getFolders } from "../../folder/service/get_folder.js"; import type { GetSharedFoldersResponse } from "../models.js"; const buildSharedFoldersNextPageUrl = ( diff --git a/packages/api/src/archive/service/index.ts b/packages/api/src/archive/service/index.ts index 94b35581..9cfada04 100644 --- a/packages/api/src/archive/service/index.ts +++ b/packages/api/src/archive/service/index.ts @@ -10,6 +10,7 @@ import { searchArchives } from "./search_archives.js"; import { updateArchive } from "./update_archive.js"; import { getArchive } from "./get_archive.js"; import { getArchives } from "./get_archives.js"; +import { createArchive } from "./create_archive.js"; export const archiveService = { getPublicTags, @@ -24,4 +25,5 @@ export const archiveService = { updateArchive, getArchive, getArchives, + createArchive, }; diff --git a/packages/api/src/archive/validators.test.ts b/packages/api/src/archive/validators.test.ts index c538587c..bd20e090 100644 --- a/packages/api/src/archive/validators.test.ts +++ b/packages/api/src/archive/validators.test.ts @@ -1,4 +1,7 @@ -import { validateArchiveIdFromParams } from "./validators.js"; +import { + validateArchiveIdFromParams, + validateCreateArchiveRequest, +} from "./validators.js"; import { describe, expect, test } from "vitest"; describe("validateArchiveIdFromParams", () => { @@ -39,3 +42,60 @@ describe("validateArchiveIdFromParams", () => { } }); }); + +describe("validateCreateArchiveRequest", () => { + const validBody = { + emailFromAuthToken: "test@permanent.org", + userSubjectFromAuthToken: "82bd483e-914b-4bfe-abf9-92ffe86d7803", + name: "My Archive", + ip: "127.0.0.1", + }; + + test("should not throw for a valid body without type", () => { + expect(() => { + validateCreateArchiveRequest(validBody); + }).not.toThrow(); + }); + + test.each(["person", "group", "organization"])( + "should not throw for type %s", + (type) => { + expect(() => { + validateCreateArchiveRequest({ ...validBody, type }); + }).not.toThrow(); + }, + ); + + test("should throw for type nonprofit", () => { + expect(() => { + validateCreateArchiveRequest({ ...validBody, type: "nonprofit" }); + }).toThrow(); + }); + + test("should throw if name is missing", () => { + const { name: _name, ...rest } = validBody; + expect(() => { + validateCreateArchiveRequest(rest); + }).toThrow(); + }); + + test("should throw if name is empty", () => { + expect(() => { + validateCreateArchiveRequest({ ...validBody, name: "" }); + }).toThrow(); + }); + + test("should throw if emailFromAuthToken is missing", () => { + const { emailFromAuthToken: _email, ...rest } = validBody; + expect(() => { + validateCreateArchiveRequest(rest); + }).toThrow(); + }); + + test("should throw if ip is missing", () => { + const { ip: _ip, ...rest } = validBody; + expect(() => { + validateCreateArchiveRequest(rest); + }).toThrow(); + }); +}); diff --git a/packages/api/src/archive/validators.ts b/packages/api/src/archive/validators.ts index 314980e2..a28a8111 100644 --- a/packages/api/src/archive/validators.ts +++ b/packages/api/src/archive/validators.ts @@ -4,7 +4,13 @@ import { fieldsFromUserAuthentication, } from "../validators/index.js"; import { paginationFields } from "../validators/shared.js"; -import { ArchiveMembershipRole, type MilestoneSortOrder } from "./models.js"; +import { + ArchiveMembershipRole, + type CreateArchiveRequest, + type MilestoneSortOrder, +} from "./models.js"; + +const MAXIMUM_ARCHIVE_NAME_LENGTH = 1000; export { validateBodyFromAuthentication }; @@ -99,3 +105,25 @@ export const validatePatchArchiveBody: (data: unknown) => asserts data is { throw validation.error; } }; + +export const validateCreateArchiveRequest: ( + data: unknown, +) => asserts data is CreateArchiveRequest = ( + data: unknown, +): asserts data is CreateArchiveRequest => { + const validation = Joi.object() + .keys({ + ...fieldsFromUserAuthentication, + name: Joi.string() + .max(MAXIMUM_ARCHIVE_NAME_LENGTH) + .trim() + .min(1) + .required(), + type: Joi.string().valid("person", "group", "organization").optional(), + ip: Joi.string().ip().required(), + }) + .validate(data); + if (validation.error !== undefined) { + throw validation.error; + } +}; diff --git a/packages/api/src/email/fixtures/create_test_share_invitation_acceptance.sql b/packages/api/src/email/fixtures/create_test_share_invitation_acceptance.sql new file mode 100644 index 00000000..b62cfa30 --- /dev/null +++ b/packages/api/src/email/fixtures/create_test_share_invitation_acceptance.sql @@ -0,0 +1,219 @@ +INSERT INTO account ( + accountid, primaryemail, status, notificationpreferences, type, fullname +) VALUES +( + 1, + 'inviter@permanent.org', + 'status.auth.ok', + '{}', + 'type.account.standard', + 'Inviter Fullname' +), +( + 2, + 'newaccount@permanent.org', + 'status.auth.ok', + '{}', + 'type.account.standard', + 'New Account Fullname' +), +( + 3, + 'secondinviter@permanent.org', + 'status.auth.ok', + '{}', + 'type.account.standard', + 'Second Inviter Fullname' +); + +INSERT INTO archive ( + archiveid, archivenbr, public, type, status, createddt, updateddt +) VALUES +( + 1, + '0000-0000', + FALSE, + 'type.archive.person', + 'status.generic.ok', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 2, + '0001-0000', + FALSE, + 'type.archive.person', + 'status.generic.ok', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO account_archive ( + account_archiveid, + accountid, + archiveid, + accessrole, + position, + type, + status, + createddt, + updateddt +) VALUES ( + 1, + 2, + 2, + 'access.role.owner', + 0, + 'type.account.standard', + 'status.generic.ok', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO profile_item ( + archiveid, fieldnameui, string1, status, type +) VALUES +( + 1, + 'profile.basic', + 'Inviter Archive', + 'status.generic.ok', + 'type.profile_item.basic' +), +( + 2, + 'profile.basic', + 'New Archive', + 'status.generic.ok', + 'type.profile_item.basic' +); + +INSERT INTO folder ( + folderid, + archiveid, + displayname, + downloadname, + status, + type, + createddt, + updateddt +) VALUES +( + 1, + 1, + 'Shared Folder', + 'Shared Folder', + 'status.generic.ok', + 'type.folder.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 2, + 1, + 'Second Shared Folder', + 'Second Shared Folder', + 'status.generic.ok', + 'type.folder.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO folder_link ( + folder_linkid, + folderid, + archiveid, + position, + accessrole, + status, + type, + createddt, + updateddt +) VALUES +( + 1, + 1, + 1, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 2, + 2, + 1, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO invite ( + inviteid, + email, + byarchiveid, + byaccountid, + token, + status, + type, + createddt, + updateddt +) VALUES +( + 1, + 'newaccount@permanent.org', + 1, + 1, + 'token-1', + 'status.invite.accepted', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 2, + 'newaccount@permanent.org', + 1, + 3, + 'token-2', + 'status.invite.accepted', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +INSERT INTO invite_share ( + invite_shareid, + inviteid, + folder_linkid, + accessrole, + status, + type, + createddt, + updateddt +) VALUES +( + 1, + 1, + 1, + 'access.role.viewer', + 'status.invite.accepted', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +), +( + 2, + 2, + 2, + 'access.role.editor', + 'status.invite.accepted', + 'type.invite.share', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); diff --git a/packages/api/src/email/index.ts b/packages/api/src/email/index.ts index ab1c90ed..9bab1fe8 100644 --- a/packages/api/src/email/index.ts +++ b/packages/api/src/email/index.ts @@ -3,6 +3,7 @@ import { sendArchiveStewardNotification, sendInvitationNotification, sendGiftNotification, + sendShareInvitationAcceptanceNotification, } from "./service.js"; export { @@ -10,4 +11,5 @@ export { sendArchiveStewardNotification, sendInvitationNotification, sendGiftNotification, + sendShareInvitationAcceptanceNotification, }; diff --git a/packages/api/src/email/queries/get_share_invitation_acceptance_details.sql b/packages/api/src/email/queries/get_share_invitation_acceptance_details.sql new file mode 100644 index 00000000..8eec578e --- /dev/null +++ b/packages/api/src/email/queries/get_share_invitation_acceptance_details.sql @@ -0,0 +1,28 @@ +SELECT + inviter_account.primaryemail AS "inviterEmail", + inviter_account.fullname AS "inviterFullName", + new_account.fullname AS "newAccountFullName", + invite_share.accessrole AS "accessRole", + COALESCE( + shared_record.displayname, shared_folder.displayname + ) AS "shareName" +FROM invite +INNER JOIN account AS new_account + ON + LOWER(invite.email) = LOWER(new_account.primaryemail) +INNER JOIN account AS inviter_account + ON invite.byaccountid = inviter_account.accountid +INNER JOIN invite_share ON invite.inviteid = invite_share.inviteid +LEFT JOIN folder_link + ON invite_share.folder_linkid = folder_link.folder_linkid +LEFT JOIN record AS shared_record + ON folder_link.recordid = shared_record.recordid +LEFT JOIN folder AS shared_folder + ON + folder_link.folderid = shared_folder.folderid + AND folder_link.recordid IS NULL +WHERE + invite.inviteid = ANY(:inviteIds) + AND invite.type = 'type.invite.share' + AND invite.status = 'status.invite.accepted' +ORDER BY invite.inviteid DESC; diff --git a/packages/api/src/email/service.test.ts b/packages/api/src/email/service.test.ts index 353109db..f58ccc28 100644 --- a/packages/api/src/email/service.test.ts +++ b/packages/api/src/email/service.test.ts @@ -14,6 +14,7 @@ import { sendInvitationNotification, sendGiftNotification, sendEmail, + sendShareInvitationAcceptanceNotification, } from "./service.js"; import { MailchimpTransactional } from "../mailchimp.js"; import { db } from "../database.js"; @@ -477,3 +478,106 @@ describe("sendGiftNotification", () => { expect(MailchimpTransactional.messages.sendTemplate).not.toHaveBeenCalled(); }); }); + +describe("sendShareInvitationAcceptanceNotification", () => { + afterEach(async () => { + await db.query( + "TRUNCATE account, archive, account_archive, profile_item, folder, folder_link, invite, invite_share CASCADE", + ); + vi.clearAllMocks(); + }); + + test("should send the acceptance email to the original inviter", async () => { + await runFixtures(db, [ + "email.fixtures.create_test_share_invitation_acceptance", + ]); + const mockResponse: MessagesSendSuccessResponse[] = [ + { + status: "sent", + _id: "test", + email: "inviter@permanent.org", + reject_reason: null, + }, + ]; + vi.mocked(MailchimpTransactional.messages.sendTemplate).mockResolvedValue( + mockResponse, + ); + + await sendShareInvitationAcceptanceNotification(["1"]); + + expect(MailchimpTransactional.messages.sendTemplate).toHaveBeenCalledTimes( + 1, + ); + expect(MailchimpTransactional.messages.sendTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + template_name: "share-invitation-acceptance", + message: expect.objectContaining({ + to: [{ email: "inviter@permanent.org", name: "Inviter Fullname" }], + global_merge_vars: expect.arrayContaining([ + { name: "to_fullname", content: "Inviter Fullname" }, + { name: "from_fullname", content: "New Account Fullname" }, + { name: "share_name", content: "Shared Folder" }, + { name: "access_role", content: "viewer" }, + ]) as unknown, + }) as unknown, + }), + ); + }); + + test("should do nothing if there is no accepted share invite for the email", async () => { + await sendShareInvitationAcceptanceNotification([]); + expect(MailchimpTransactional.messages.sendTemplate).not.toHaveBeenCalled(); + }); + + test("should send one email per accepted share invite when multiple exist", async () => { + await runFixtures(db, [ + "email.fixtures.create_test_share_invitation_acceptance", + ]); + const mockResponse: MessagesSendSuccessResponse[] = [ + { + status: "sent", + _id: "test", + email: "someone@permanent.org", + reject_reason: null, + }, + ]; + vi.mocked(MailchimpTransactional.messages.sendTemplate).mockResolvedValue( + mockResponse, + ); + + await sendShareInvitationAcceptanceNotification(["1", "2"]); + + expect(MailchimpTransactional.messages.sendTemplate).toHaveBeenCalledTimes( + 2, + ); + expect(MailchimpTransactional.messages.sendTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + template_name: "share-invitation-acceptance", + message: expect.objectContaining({ + to: [{ email: "inviter@permanent.org", name: "Inviter Fullname" }], + global_merge_vars: expect.arrayContaining([ + { name: "share_name", content: "Shared Folder" }, + { name: "access_role", content: "viewer" }, + ]) as unknown, + }) as unknown, + }), + ); + expect(MailchimpTransactional.messages.sendTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + template_name: "share-invitation-acceptance", + message: expect.objectContaining({ + to: [ + { + email: "secondinviter@permanent.org", + name: "Second Inviter Fullname", + }, + ], + global_merge_vars: expect.arrayContaining([ + { name: "share_name", content: "Second Shared Folder" }, + { name: "access_role", content: "editor" }, + ]) as unknown, + }) as unknown, + }), + ); + }); +}); diff --git a/packages/api/src/email/service.ts b/packages/api/src/email/service.ts index 0b8d6c1a..89bf3109 100644 --- a/packages/api/src/email/service.ts +++ b/packages/api/src/email/service.ts @@ -1,5 +1,9 @@ import { MailchimpTransactional } from "../mailchimp.js"; import { db } from "../database.js"; +import { + type AccessRole, + accessRoleToArchiveMembershipRole, +} from "../access/models.js"; const defaultFromEmail = "support@permanent.org"; const defaultMessage = { @@ -176,3 +180,52 @@ export const sendGiftNotification = async ( ], }); }; + +export const sendShareInvitationAcceptanceNotification = async ( + inviteIds: string[], +): Promise => { + const detailsResult = await db.sql<{ + inviterEmail: string; + inviterFullName: string; + newAccountFullName: string; + shareName: string; + accessRole: AccessRole; + }>("email.queries.get_share_invitation_acceptance_details", { + inviteIds, + }); + const { rows } = detailsResult; + + const clickUrl = `https://${process.env["SITE_URL"] ?? ""}/app/login`; + + await Promise.all( + rows.map( + async (details: { + inviterEmail: string; + inviterFullName: string; + newAccountFullName: string; + shareName: string; + accessRole: AccessRole; + }) => { + const prettyAccessRole = accessRoleToArchiveMembershipRole( + details.accessRole, + ); + await sendEmail("share-invitation-acceptance", { + fromName: details.newAccountFullName, + toData: [ + { email: details.inviterEmail, name: details.inviterFullName }, + ], + subject: "", + mergeVariables: [ + { name: "to_fullname", content: details.inviterFullName }, + { name: "from_fullname", content: details.newAccountFullName }, + { name: "share_name", content: details.shareName }, + { name: "access_role", content: prettyAccessRole }, + { name: "click_url", content: clickUrl }, + { name: "from_pa_name", content: details.newAccountFullName }, + { name: "to_pa_name", content: details.inviterFullName }, + ], + }); + }, + ), + ); +}; diff --git a/packages/api/src/folder/controller/controller.ts b/packages/api/src/folder/controller/controller.ts index 5cea268e..ca629e01 100644 --- a/packages/api/src/folder/controller/controller.ts +++ b/packages/api/src/folder/controller/controller.ts @@ -9,13 +9,7 @@ import { extractUserEmailFromAuthToken, verifyUserAuthentication, } from "../../middleware/index.js"; -import { - patchFolder, - getFolders, - getFoldersPage, - getFolderChildren, - getFolderShareLinks, -} from "../service.js"; +import { folderService } from "../service/index.js"; import { validatePatchFolderRequest, validateFolderRequest, @@ -38,8 +32,11 @@ folderController.patch( try { validateFolderRequest(req.params); validatePatchFolderRequest(req.body); - const folderId = await patchFolder(req.params.folderId, req.body); - const [folder] = await getFolders( + const folderId = await folderService.patchFolder( + req.params.folderId, + req.body, + ); + const [folder] = await folderService.getFolders( [folderId], req.body.emailFromAuthToken, ); @@ -65,7 +62,7 @@ folderController.get( try { validateOptionalAuthenticationValues(req.body); validateGetFoldersQuery(req.query); - const folders = await getFolders( + const folders = await folderService.getFolders( req.query.folderIds, req.body.emailFromAuthToken, req.body.shareToken, @@ -86,7 +83,7 @@ folderController.get( validateOptionalAuthenticationValues(req.body); validatePaginationParameters(req.query); validateFolderRequest(req.params); - const response = await getFolderChildren( + const response = await folderService.getFolderChildren( req.params.folderId, { pageSize: req.query.pageSize, cursor: req.query.cursor }, req.body.emailFromAuthToken, @@ -106,7 +103,7 @@ folderController.get( try { validateFolderRequest(req.params); validateBodyFromAuthentication(req.body); - const shareLinks = await getFolderShareLinks( + const shareLinks = await folderService.getFolderShareLinks( req.body.emailFromAuthToken, req.params.folderId, ); @@ -127,7 +124,7 @@ foldersController.get( try { validateOptionalAuthenticationValues(req.body); validateGetFoldersPageQuery(req.query); - const response = await getFoldersPage({ + const response = await folderService.getFoldersPage({ folderIds: req.query.folderIds, email: req.body.emailFromAuthToken, shareToken: req.body.shareToken, diff --git a/packages/api/src/folder/queries/create_folder.sql b/packages/api/src/folder/queries/create_folder.sql new file mode 100644 index 00000000..924d0398 --- /dev/null +++ b/packages/api/src/folder/queries/create_folder.sql @@ -0,0 +1,85 @@ +WITH new_folder AS ( + INSERT INTO folder ( + archiveid, + archivenbr, + displayname, + downloadname, + description, + type, + status, + publicdt, + createddt, + updateddt + ) VALUES ( + :archiveId, + :archiveNbr, + :displayName, + :downloadName, + :description, + :type, + 'status.generic.ok', + :publicDt, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + RETURNING folderid +), + +new_archive_nbr AS ( + INSERT INTO archive_nbr ( + archivenbr, + reftable, + refid, + archivepart, + itempart, + status, + type, + createddt, + updateddt + ) + SELECT + :archiveNbr, + 'folder' AS reftable, + folderid, + :archivePart, + :itemPart, + 'status.generic.ok' AS status, + 'type.generic.placeholder' AS type, + CURRENT_TIMESTAMP AS createddt, + CURRENT_TIMESTAMP AS updateddt + FROM new_folder +), + +new_folder_link AS ( + INSERT INTO folder_link ( + folderid, + parentfolderid, + parentfolder_linkid, + archiveid, + position, + accessrole, + status, + type, + createddt, + updateddt + ) + SELECT + folderid, + :parentFolderId, + :parentFolderLinkId, + :archiveId, + :position, + 'access.role.owner' AS accessrole, + 'status.generic.ok' AS status, + :folderLinkType, + CURRENT_TIMESTAMP AS createddt, + CURRENT_TIMESTAMP AS updateddt + FROM new_folder + RETURNING folder_linkid +) + +SELECT + new_folder.folderid AS "folderId", + new_folder_link.folder_linkid AS "folderLinkId" +FROM new_folder +CROSS JOIN new_folder_link; diff --git a/packages/api/src/folder/service/create_default_folders.test.ts b/packages/api/src/folder/service/create_default_folders.test.ts new file mode 100644 index 00000000..180f9dbf --- /dev/null +++ b/packages/api/src/folder/service/create_default_folders.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, afterEach, describe, expect, test, vi } from "vitest"; +import { db } from "../../database.js"; +import { createDefaultFolders } from "./create_default_folders.js"; +import { runFixtures } from "../../../test/run_fixtures.js"; + +vi.mock("../../database"); + +const loadFixtures = async (): Promise => { + await runFixtures(db, [ + "folder.fixtures.create_test_accounts", + "folder.fixtures.create_test_archives", + ]); +}; + +const clearDatabase = async (): Promise => { + await db.query( + "TRUNCATE account, archive, archive_nbr, folder, folder_link CASCADE", + ); +}; + +describe("createDefaultFolders", () => { + beforeEach(async () => { + await loadFixtures(); + }); + afterEach(async () => { + await clearDatabase(); + }); + + test("should create a root folder and 3 children", async () => { + const archiveId = "1"; + const { rootFolderId } = await createDefaultFolders(archiveId, "0000", db); + + const foldersResult = await db.query<{ + displayname: string; + type: string; + description: string | null; + publicdt: Date | null; + }>( + "SELECT displayname, type, description, publicdt FROM folder WHERE archiveid = :archiveId ORDER BY folderid", + { archiveId }, + ); + const { rows: folderRows } = foldersResult; + expect(folderRows).toHaveLength(4); + const [root, apps, myFiles, publicFolder] = folderRows; + expect(root).toMatchObject({ + displayname: "Archive Root", + type: "type.folder.root.root", + description: null, + }); + expect(apps).toMatchObject({ + displayname: "Apps", + type: "type.folder.root.app", + description: "pages.apps.description", + }); + expect(myFiles).toMatchObject({ + displayname: "My Files", + type: "type.folder.root.private", + description: "pages.private.description", + }); + expect(publicFolder).toMatchObject({ + displayname: "Public", + type: "type.folder.root.public", + description: "pages.public.description", + }); + expect(publicFolder?.publicdt).not.toBeNull(); + + const rootRow = await db.query<{ folderid: string }>( + "SELECT folderid FROM folder WHERE type = 'type.folder.root.root' AND archiveid = :archiveId", + { archiveId }, + ); + expect(rootFolderId).toEqual(rootRow.rows[0]?.folderid); + }); + + test("should link all 3 children under the root folder_link", async () => { + const archiveId = "1"; + const { rootFolderId } = await createDefaultFolders(archiveId, "0000", db); + + const rootLinkResult = await db.query<{ folder_linkid: string }>( + "SELECT folder_linkid FROM folder_link WHERE folderid = :rootFolderId", + { rootFolderId }, + ); + const rootLinkId = rootLinkResult.rows[0]?.folder_linkid; + + const childLinksResult = await db.query<{ + parentfolderid: string; + parentfolder_linkid: string; + accessrole: string; + position: string; + }>( + "SELECT parentfolderid, parentfolder_linkid, accessrole, position FROM folder_link WHERE parentFolderId = :rootFolderId", + { rootFolderId, archiveId }, + ); + expect(childLinksResult.rows).toHaveLength(3); + childLinksResult.rows.forEach((row) => { + expect(row.parentfolderid).toEqual(rootFolderId); + expect(row.parentfolder_linkid).toEqual(rootLinkId); + expect(row.accessrole).toEqual("access.role.owner"); + }); + }); + + test("should assign sequential archive_nbr item parts starting at 0001", async () => { + const archiveId = "1"; + await createDefaultFolders(archiveId, "0000", db); + + const result = await db.query<{ itempart: string }>( + "SELECT itempart FROM archive_nbr WHERE reftable = 'folder' ORDER BY itempart", + ); + expect(result.rows.map((row) => row.itempart)).toEqual([ + "0001", + "0002", + "0003", + "0004", + ]); + }); +}); diff --git a/packages/api/src/folder/service/create_default_folders.ts b/packages/api/src/folder/service/create_default_folders.ts new file mode 100644 index 00000000..c47d4bd8 --- /dev/null +++ b/packages/api/src/folder/service/create_default_folders.ts @@ -0,0 +1,131 @@ +import createError from "http-errors"; +import { logger } from "@stela/logger"; +import type { TinyPg } from "tinypg"; +import { FolderType } from "../models.js"; + +interface CreateFolderParams { + archiveId: string; + archiveNbr: string; + archivePart: string; + itemPart: string; + displayName: string; + downloadName: string; + description: string | null; + type: FolderType; + publicDt: string | null; + parentFolderId: string | null; + parentFolderLinkId: string | null; + position: number; + folderLinkType: string; +} + +const createFolder = async ( + params: CreateFolderParams, + transactionDb: TinyPg, +): Promise<{ folderId: string; folderLinkId: string }> => { + const result = await transactionDb + .sql<{ + folderId: string; + folderLinkId: string; + }>("folder.queries.create_folder", params) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError("Failed to create folder"); + }); + const { rows } = result; + const [folder] = rows; + if (folder === undefined) { + throw new createError.InternalServerError("Failed to create folder"); + } + return folder; +}; + +const yesterdayIsoString = (): string => { + const date = new Date(); + date.setDate(date.getDate() - 1); + return date.toISOString(); +}; + +export const createDefaultFolders = async ( + archiveId: string, + archivePart: string, + db: TinyPg, +): Promise<{ rootFolderId: string }> => { + const root = await createFolder( + { + archiveId, + archiveNbr: `${archivePart}-0001`, + archivePart, + itemPart: "0001", + displayName: "Archive Root", + downloadName: "Archive Root", + description: null, + type: FolderType.RootRoot, + publicDt: null, + parentFolderId: null, + parentFolderLinkId: null, + position: 1, + folderLinkType: "type.folder_link.root.root", + }, + db, + ); + + await createFolder( + { + archiveId, + archiveNbr: `${archivePart}-0002`, + archivePart, + itemPart: "0002", + displayName: "Apps", + downloadName: "Apps", + description: "pages.apps.description", + type: FolderType.RootApp, + publicDt: null, + parentFolderId: root.folderId, + parentFolderLinkId: root.folderLinkId, + position: 1, + folderLinkType: "type.folder_link.root.app", + }, + db, + ); + + await createFolder( + { + archiveId, + archiveNbr: `${archivePart}-0003`, + archivePart, + itemPart: "0003", + displayName: "My Files", + downloadName: "My Files", + description: "pages.private.description", + type: FolderType.RootPrivate, + publicDt: null, + parentFolderId: root.folderId, + parentFolderLinkId: root.folderLinkId, + position: 1, + folderLinkType: "type.folder_link.root.private", + }, + db, + ); + + await createFolder( + { + archiveId, + archiveNbr: `${archivePart}-0004`, + archivePart, + itemPart: "0004", + displayName: "Public", + downloadName: "Public", + description: "pages.public.description", + type: FolderType.RootPublic, + publicDt: yesterdayIsoString(), + parentFolderId: root.folderId, + parentFolderLinkId: root.folderLinkId, + position: 1, + folderLinkType: "type.folder_link.root.public", + }, + db, + ); + + return { rootFolderId: root.folderId }; +}; diff --git a/packages/api/src/folder/service.test.ts b/packages/api/src/folder/service/get_folder.test.ts similarity index 98% rename from packages/api/src/folder/service.test.ts rename to packages/api/src/folder/service/get_folder.test.ts index 772a6df5..338ef28a 100644 --- a/packages/api/src/folder/service.test.ts +++ b/packages/api/src/folder/service/get_folder.test.ts @@ -3,7 +3,7 @@ import { prettifyFolderType, prettifyFolderStatus, prettifyFolderView, -} from "./service.js"; +} from "./get_folder.js"; import { FolderSortOrder, PrettyFolderSortOrder, @@ -13,7 +13,7 @@ import { PrettyFolderStatus, FolderView, PrettyFolderView, -} from "./models.js"; +} from "../models.js"; import { describe, expect, test } from "vitest"; describe("prettifyFolderType", () => { diff --git a/packages/api/src/folder/service.ts b/packages/api/src/folder/service/get_folder.ts similarity index 66% rename from packages/api/src/folder/service.ts rename to packages/api/src/folder/service/get_folder.ts index 7633bcab..16ea712c 100644 --- a/packages/api/src/folder/service.ts +++ b/packages/api/src/folder/service/get_folder.ts @@ -1,15 +1,13 @@ import createError from "http-errors"; +import { db } from "../../database.js"; import { logger } from "@stela/logger"; -import type { TinyPg } from "tinypg"; -import { db } from "../database.js"; import type { FolderRow, Folder, - PatchFolderRequest, GetFolderChildrenResponse, GetFoldersResponse, FolderChildItem, -} from "./models.js"; +} from "../models.js"; import { FolderType, FolderStatus, @@ -19,21 +17,17 @@ import { PrettyFolderType, PrettyFolderStatus, PrettyFolderView, -} from "./models.js"; -import { - getFolderAccessRole, - accessRoleLessThan, - resolveAccessRole, -} from "../access/permission.js"; +} from "../models.js"; import { AccessRole, archiveMembershipRoleToAccessRole, -} from "../access/models.js"; -import { getRecords } from "../record/service.js"; -import { shareLinkService } from "../share_link/service.js"; -import type { ShareLink } from "../share_link/models.js"; -import { insertLocation, updateLocation } from "../location/service.js"; -import { ShareStatus } from "../share/models.js"; +} from "../../access/models.js"; +import { + resolveAccessRole, + accessRoleLessThan, +} from "../../access/permission.js"; +import { getRecords } from "../../record/service.js"; +import { ShareStatus } from "../../share/models.js"; export const prettifyFolderSortType = ( sortType: FolderSortOrder, @@ -292,114 +286,3 @@ export const getFolderChildren = async ( }, }; }; - -const validateCanPatchFolder = async ( - folderId: string, - emailFromAuthToken: string, -): Promise => { - const accessRole = await getFolderAccessRole(folderId, emailFromAuthToken); - if (accessRoleLessThan(accessRole, AccessRole.Editor)) { - throw new createError.Forbidden( - "User does not have permission to modify folder.", - ); - } -}; - -const getFolderLocationId = async ( - folderId: string, - client: TinyPg, -): Promise => { - const result = await client - .sql<{ locationId: string | null }>( - "folder.queries.get_folder_location_id", - { - folderId, - }, - ) - .catch((err: unknown) => { - logger.error(err); - throw new createError.InternalServerError("Failed to look up folder"); - }); - const { rows } = result; - const [row] = rows; - if (row === undefined) { - throw new createError.NotFound(`Folder ${folderId} not found`); - } - return row.locationId; -}; - -export const patchFolder = async ( - folderId: string, - folderData: PatchFolderRequest, -): Promise => { - await validateCanPatchFolder(folderId, folderData.emailFromAuthToken); - - return await db.transaction(async (transactionDb) => { - let locationId: string | null = null; - if (folderData.location !== undefined) { - const currentLocationId = await getFolderLocationId( - folderId, - transactionDb, - ); - if (currentLocationId === null) { - locationId = await insertLocation(folderData.location, transactionDb); - } else { - await updateLocation( - currentLocationId, - folderData.location, - transactionDb, - ); - locationId = currentLocationId; - } - } - - const result = await transactionDb - .sql<{ folderId: string }>("folder.queries.update_folder", { - folderId, - displayDate: folderData.displayDate, - setDisplayDateToNull: folderData.displayDate === null, - displayEndDate: folderData.displayEndDate, - setDisplayEndDateToNull: folderData.displayEndDate === null, - displayTime: folderData.displayTime, - setDisplayTimeToNull: folderData.displayTime === null, - timezone: folderData.location?.timezone, - setTimezoneToNull: folderData.location?.timezone === null, - locationId, - }) - .catch((err: unknown) => { - logger.error(err); - throw new createError.InternalServerError("Failed to update folder"); - }); - - if (result.rows[0] === undefined) { - throw new createError.NotFound("Folder not found"); - } - return result.rows[0].folderId; - }); -}; - -export const getFolderShareLinks = async ( - email: string, - folderId: string, -): Promise => { - const folderShareLinkIds = await db - .sql<{ id: string }>("folder.queries.get_folder_share_links", { - email, - folderId, - }) - .catch((err: unknown) => { - logger.error(err); - throw new createError.InternalServerError( - "Failed to get folder share links", - ); - }); - - const shareLinkIds = folderShareLinkIds.rows.map((row) => row.id); - const shareLinks = await shareLinkService.getShareLinks( - email, - [], - shareLinkIds, - { pageSize: null, cursor: undefined }, - ); - return shareLinks.items; -}; diff --git a/packages/api/src/folder/service/get_folder_share_links.ts b/packages/api/src/folder/service/get_folder_share_links.ts new file mode 100644 index 00000000..d4f37c9e --- /dev/null +++ b/packages/api/src/folder/service/get_folder_share_links.ts @@ -0,0 +1,31 @@ +import createError from "http-errors"; +import { logger } from "@stela/logger"; +import { db } from "../../database.js"; +import { shareLinkService } from "../../share_link/service.js"; +import type { ShareLink } from "../../share_link/models.js"; + +export const getFolderShareLinks = async ( + email: string, + folderId: string, +): Promise => { + const folderShareLinkIds = await db + .sql<{ id: string }>("folder.queries.get_folder_share_links", { + email, + folderId, + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError( + "Failed to get folder share links", + ); + }); + + const shareLinkIds = folderShareLinkIds.rows.map((row) => row.id); + const shareLinks = await shareLinkService.getShareLinks( + email, + [], + shareLinkIds, + { pageSize: null, cursor: undefined }, + ); + return shareLinks.items; +}; diff --git a/packages/api/src/folder/service/index.ts b/packages/api/src/folder/service/index.ts new file mode 100644 index 00000000..32f4a8fc --- /dev/null +++ b/packages/api/src/folder/service/index.ts @@ -0,0 +1,13 @@ +import { patchFolder } from "./update_folder.js"; +import { getFolders, getFoldersPage, getFolderChildren } from "./get_folder.js"; +import { getFolderShareLinks } from "./get_folder_share_links.js"; +import { createDefaultFolders } from "./create_default_folders.js"; + +export const folderService = { + getFolders, + getFoldersPage, + getFolderChildren, + patchFolder, + getFolderShareLinks, + createDefaultFolders, +}; diff --git a/packages/api/src/folder/service/update_folder.ts b/packages/api/src/folder/service/update_folder.ts new file mode 100644 index 00000000..ddd0b224 --- /dev/null +++ b/packages/api/src/folder/service/update_folder.ts @@ -0,0 +1,96 @@ +import createError from "http-errors"; +import { logger } from "@stela/logger"; +import type { TinyPg } from "tinypg"; +import { db } from "../../database.js"; +import type { PatchFolderRequest } from "../models.js"; +import { + getFolderAccessRole, + accessRoleLessThan, +} from "../../access/permission.js"; +import { AccessRole } from "../../access/models.js"; +import { insertLocation, updateLocation } from "../../location/service.js"; + +const validateCanPatchFolder = async ( + folderId: string, + emailFromAuthToken: string, +): Promise => { + const accessRole = await getFolderAccessRole(folderId, emailFromAuthToken); + if (accessRoleLessThan(accessRole, AccessRole.Editor)) { + throw new createError.Forbidden( + "User does not have permission to modify folder.", + ); + } +}; + +const getFolderLocationId = async ( + folderId: string, + client: TinyPg, +): Promise => { + const result = await client + .sql<{ locationId: string | null }>( + "folder.queries.get_folder_location_id", + { + folderId, + }, + ) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError("Failed to look up folder"); + }); + const { rows } = result; + const [row] = rows; + if (row === undefined) { + throw new createError.NotFound(`Folder ${folderId} not found`); + } + return row.locationId; +}; + +export const patchFolder = async ( + folderId: string, + folderData: PatchFolderRequest, +): Promise => { + await validateCanPatchFolder(folderId, folderData.emailFromAuthToken); + + return await db.transaction(async (transactionDb) => { + let locationId: string | null = null; + if (folderData.location !== undefined) { + const currentLocationId = await getFolderLocationId( + folderId, + transactionDb, + ); + if (currentLocationId === null) { + locationId = await insertLocation(folderData.location, transactionDb); + } else { + await updateLocation( + currentLocationId, + folderData.location, + transactionDb, + ); + locationId = currentLocationId; + } + } + + const result = await transactionDb + .sql<{ folderId: string }>("folder.queries.update_folder", { + folderId, + displayDate: folderData.displayDate, + setDisplayDateToNull: folderData.displayDate === null, + displayEndDate: folderData.displayEndDate, + setDisplayEndDateToNull: folderData.displayEndDate === null, + displayTime: folderData.displayTime, + setDisplayTimeToNull: folderData.displayTime === null, + timezone: folderData.location?.timezone, + setTimezoneToNull: folderData.location?.timezone === null, + locationId, + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError("Failed to update folder"); + }); + + if (result.rows[0] === undefined) { + throw new createError.NotFound("Folder not found"); + } + return result.rows[0].folderId; + }); +}; diff --git a/packages/api/src/record/service.ts b/packages/api/src/record/service.ts index 34682618..bd920573 100644 --- a/packages/api/src/record/service.ts +++ b/packages/api/src/record/service.ts @@ -21,7 +21,7 @@ import { } from "../access/models.js"; import { shareLinkService } from "../share_link/service.js"; import type { ShareLink } from "../share_link/models.js"; -import { getFolders } from "../folder/service.js"; +import { getFolders } from "../folder/service/get_folder.js"; import { type Folder, PrettyFolderType } from "../folder/models.js"; import { insertLocation, updateLocation } from "../location/service.js"; import { ShareStatus } from "../share/models.js";