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/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; +}; 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";