From 340aaa04bb984ec17700bee5c7d2ec828433b6f7 Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Fri, 10 May 2024 16:32:02 +0100 Subject: [PATCH 1/8] Add missing explicit foreign key fields --- .../1715355140741-ImproveContactFieldTypes.ts | 49 +++++++++++++++++++ src/models/ApiKey.ts | 4 +- src/models/ContactMfa.ts | 2 + src/models/GiftFlow.ts | 2 +- src/models/Project.ts | 8 +-- src/models/UploadFlow.ts | 2 + 6 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 src/migrations/1715355140741-ImproveContactFieldTypes.ts diff --git a/src/migrations/1715355140741-ImproveContactFieldTypes.ts b/src/migrations/1715355140741-ImproveContactFieldTypes.ts new file mode 100644 index 000000000..b7d7464cd --- /dev/null +++ b/src/migrations/1715355140741-ImproveContactFieldTypes.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ImproveContactFieldTypes1715355140741 + implements MigrationInterface +{ + name = "ImproveContactFieldTypes1715355140741"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "contact_mfa" DROP CONSTRAINT "FK_c40227151c460b576a5670bdac5"` + ); + await queryRunner.query( + `ALTER TABLE "contact_mfa" ALTER COLUMN "contactId" SET NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "project" DROP CONSTRAINT "FK_9884b2ee80eb70b7db4f12e8aed"` + ); + await queryRunner.query( + `ALTER TABLE "project" ALTER COLUMN "ownerId" DROP NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "contact_mfa" ADD CONSTRAINT "FK_c40227151c460b576a5670bdac5" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "project" ADD CONSTRAINT "FK_9884b2ee80eb70b7db4f12e8aed" FOREIGN KEY ("ownerId") REFERENCES "contact"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "project" DROP CONSTRAINT "FK_9884b2ee80eb70b7db4f12e8aed"` + ); + await queryRunner.query( + `ALTER TABLE "contact_mfa" DROP CONSTRAINT "FK_c40227151c460b576a5670bdac5"` + ); + await queryRunner.query( + `ALTER TABLE "project" ALTER COLUMN "ownerId" SET NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "project" ADD CONSTRAINT "FK_9884b2ee80eb70b7db4f12e8aed" FOREIGN KEY ("ownerId") REFERENCES "contact"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "contact_mfa" ALTER COLUMN "contactId" DROP NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "contact_mfa" ADD CONSTRAINT "FK_c40227151c460b576a5670bdac5" FOREIGN KEY ("contactId") REFERENCES "contact"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + } +} diff --git a/src/models/ApiKey.ts b/src/models/ApiKey.ts index 63ad30f5f..487e6a774 100644 --- a/src/models/ApiKey.ts +++ b/src/models/ApiKey.ts @@ -14,7 +14,9 @@ export default class ApiKey { @PrimaryColumn() id!: string; - @ManyToOne("Contact") + @Column({ type: String, nullable: true }) + creatorId!: string | null; + @ManyToOne("Contact", { nullable: true }) creator!: Contact; @CreateDateColumn() diff --git a/src/models/ContactMfa.ts b/src/models/ContactMfa.ts index 81e77d1fa..415d23bdd 100644 --- a/src/models/ContactMfa.ts +++ b/src/models/ContactMfa.ts @@ -18,6 +18,8 @@ export default class ContactMfa { @PrimaryGeneratedColumn("uuid") id!: string; + @Column() + contactId!: string; @OneToOne("Contact", "mfa") @JoinColumn() contact!: Contact; diff --git a/src/models/GiftFlow.ts b/src/models/GiftFlow.ts index 36aa94099..c94a322bb 100644 --- a/src/models/GiftFlow.ts +++ b/src/models/GiftFlow.ts @@ -68,5 +68,5 @@ export default class GiftFlow { @Column({ type: String, nullable: true }) gifteeId!: string | null; @ManyToOne("Contact") - giftee?: Contact; + giftee!: Contact | null; } diff --git a/src/models/Project.ts b/src/models/Project.ts index d4432a01a..eeced3d44 100644 --- a/src/models/Project.ts +++ b/src/models/Project.ts @@ -18,10 +18,10 @@ export default class Project { @CreateDateColumn() date!: Date; - @Column() - ownerId!: string; - @ManyToOne("Contact") - owner!: Contact; + @Column({ type: String, nullable: true }) + ownerId!: string | null; + @ManyToOne("Contact", { nullable: true }) + owner!: Contact | null; @Column() title!: string; diff --git a/src/models/UploadFlow.ts b/src/models/UploadFlow.ts index be9bd59ea..a92ce6045 100644 --- a/src/models/UploadFlow.ts +++ b/src/models/UploadFlow.ts @@ -12,6 +12,8 @@ export default class UploadFlow { @PrimaryGeneratedColumn("uuid") id!: string; + @Column({ type: String, nullable: true }) + contactId!: string | null; @ManyToOne("Contact", { nullable: true }) contact!: Contact | null; From 79014707ab0cb0edca5078fa3258619a570ebc29 Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Fri, 10 May 2024 16:26:21 +0100 Subject: [PATCH 2/8] Move API key logic to a service --- src/api/controllers/ApiKeyController.ts | 25 +++++------ src/core/services/ApiKeyService.ts | 55 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 src/core/services/ApiKeyService.ts diff --git a/src/api/controllers/ApiKeyController.ts b/src/api/controllers/ApiKeyController.ts index ca65172e4..2a4bdd46e 100644 --- a/src/api/controllers/ApiKeyController.ts +++ b/src/api/controllers/ApiKeyController.ts @@ -13,11 +13,7 @@ import { Param } from "routing-controllers"; -import { getRepository } from "@core/database"; -import { generateApiKey } from "@core/utils/auth"; - -import ApiKey from "@models/ApiKey"; -import Contact from "@models/Contact"; +import ApiKeyService from "@core/services/ApiKeyService"; import { CurrentAuth } from "@api/decorators/CurrentAuth"; import { @@ -29,6 +25,8 @@ import { import { PaginatedDto } from "@api/dto/PaginatedDto"; import ApiKeyTransformer from "@api/transformers/ApiKeyTransformer"; +import Contact from "@models/Contact"; + import { AuthInfo } from "@type/auth-info"; @JsonController("/api-key") @@ -56,15 +54,11 @@ export class ApiKeyController { @CurrentUser({ required: true }) creator: Contact, @Body() data: CreateApiKeyDto ): Promise { - const { id, secretHash, token } = generateApiKey(); - - await getRepository(ApiKey).save({ - id, - secretHash, + const token = await ApiKeyService.create( creator, - description: data.description, - expires: data.expires - }); + data.description, + data.expires + ); return plainToInstance(NewApiKeyDto, { token }); } @@ -72,7 +66,8 @@ export class ApiKeyController { @OnUndefined(204) @Delete("/:id") async deleteApiKey(@Param("id") id: string): Promise { - const result = await getRepository(ApiKey).delete(id); - if (!result.affected) throw new NotFoundError(); + if (!(await ApiKeyService.delete(id))) { + throw new NotFoundError(); + } } } diff --git a/src/core/services/ApiKeyService.ts b/src/core/services/ApiKeyService.ts new file mode 100644 index 000000000..245e11cd6 --- /dev/null +++ b/src/core/services/ApiKeyService.ts @@ -0,0 +1,55 @@ +import { getRepository } from "@core/database"; +import { generateApiKey } from "@core/utils/auth"; + +import ApiKey from "@models/ApiKey"; +import Contact from "@models/Contact"; + +class ApiKeyService { + /** + * Create a new API key + * @param creator The contact that created the API key + * @param description A description of the API key + * @param expires When the API key expires, or null if it never expires + * @returns the new API key token + */ + async create( + creator: Contact, + description: string, + expires: Date | null + ): Promise { + const { id, secretHash, token } = generateApiKey(); + + await getRepository(ApiKey).save({ + id, + secretHash, + creator, + description, + expires + }); + + return token; + } + + /** + * Delete an API key + * @param id The API key ID + * @returns Whether the API key was deleted + */ + async delete(id: string): Promise { + const res = await getRepository(ApiKey).delete({ id }); + return !!res.affected; + } + + /** + * Permanently disassociate an API key from a contact + * @param contact The contact + */ + async permanentlyDeleteContact(contact: Contact) { + await getRepository(ApiKey).update( + { creatorId: contact.id }, + { creatorId: null } + ); + } +} + +export default new ApiKeyService(); From 513a4c7abd9ef6215aaea6b3f58c87bd84f4d4ba Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Fri, 10 May 2024 16:28:24 +0100 Subject: [PATCH 3/8] Move upload flow logic to a service --- src/api/controllers/UploadController.ts | 43 ++----------- src/core/services/UploadFlowService.ts | 83 +++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 39 deletions(-) create mode 100644 src/core/services/UploadFlowService.ts diff --git a/src/api/controllers/UploadController.ts b/src/api/controllers/UploadController.ts index 1bb67f738..06306007b 100644 --- a/src/api/controllers/UploadController.ts +++ b/src/api/controllers/UploadController.ts @@ -1,10 +1,8 @@ import { plainToInstance } from "class-transformer"; -import { sub } from "date-fns"; import { Request } from "express"; import { CurrentUser, Get, - HttpError, JsonController, NotFoundError, OnUndefined, @@ -12,26 +10,15 @@ import { Post, Req } from "routing-controllers"; -import { MoreThan } from "typeorm"; -import { getRepository } from "@core/database"; +import UploadFlowService from "@core/services/UploadFlowService"; import Contact from "@models/Contact"; -import UploadFlow from "@models/UploadFlow"; import { GetUploadFlowDto } from "@api/dto/UploadFlowDto"; import BadRequestError from "@api/errors/BadRequestError"; import { UUIDParams } from "@api/params/UUIDParams"; -async function canUploadOrFail(ipAddress: string, date: Date, max: number) { - const uploadFlows = await getRepository(UploadFlow).find({ - where: { ipAddress, date: MoreThan(date) } - }); - if (uploadFlows.length >= max) { - throw new HttpError(429, "Too many upload requests"); - } -} - @JsonController("/upload") export class UploadController { @Post("/") @@ -43,23 +30,8 @@ export class UploadController { throw new BadRequestError(); } - // No more than 10 uploads in a minute for all users - const oneMinAgo = sub(new Date(), { minutes: 1 }); - await canUploadOrFail(req.ip, oneMinAgo, 10); - - // No more than 20 uploads in an hour for non-authed users - if (!contact) { - const oneHourAgo = sub(new Date(), { hours: 1 }); - await canUploadOrFail(req.ip, oneHourAgo, 20); - } - - const newUploadFlow = await getRepository(UploadFlow).save({ - contact: contact || null, - ipAddress: req.ip, - used: false - }); - - return plainToInstance(GetUploadFlowDto, { id: newUploadFlow.id }); + const newUploadFlowId = await UploadFlowService.create(contact, req.ip); + return plainToInstance(GetUploadFlowDto, { id: newUploadFlowId }); } // This should be a POST request as it's not idempotent, but we use nginx's @@ -67,14 +39,7 @@ export class UploadController { @Get("/:id") @OnUndefined(204) async get(@Params() { id }: UUIDParams): Promise { - // Flows are valid for a minute - const oneMinAgo = sub(new Date(), { minutes: 1 }); - const res = await getRepository(UploadFlow).update( - { id, date: MoreThan(oneMinAgo), used: false }, - { used: true } - ); - - if (!res.affected) { + if (!(await UploadFlowService.validate(id))) { throw new NotFoundError(); } } diff --git a/src/core/services/UploadFlowService.ts b/src/core/services/UploadFlowService.ts new file mode 100644 index 000000000..1470c822b --- /dev/null +++ b/src/core/services/UploadFlowService.ts @@ -0,0 +1,83 @@ +import { sub } from "date-fns"; +import { HttpError } from "routing-controllers"; +import { MoreThan } from "typeorm"; + +import { getRepository } from "@core/database"; + +import Contact from "@models/Contact"; +import UploadFlow from "@models/UploadFlow"; + +class UploadFlowService { + /** + * Create an upload flow for the given contact and IP address, checking that they + * have not exceeded the rate limits. + * @param contact The contact + * @param ipAddress The IP address + * @returns + */ + async create( + contact: Contact | undefined, + ipAddress: string + ): Promise { + // No more than 10 uploads in a minute for all users + const oneMinAgo = sub(new Date(), { minutes: 1 }); + await this.canUploadOrFail(ipAddress, oneMinAgo, 10); + + // No more than 20 uploads in an hour for non-authed users + if (!contact) { + const oneHourAgo = sub(new Date(), { hours: 1 }); + await this.canUploadOrFail(ipAddress, oneHourAgo, 20); + } + + const newUploadFlow = await getRepository(UploadFlow).save({ + contact: contact || null, + ipAddress, + used: false + }); + + return newUploadFlow.id; + } + + /** + * Validate an upload flow ID, marking it as used if it is valid. + * @param id The flow ID + * @returns whether the flow was valid + */ + async validate(id: string): Promise { + // Flows are valid for one minute + const oneMinAgo = sub(new Date(), { minutes: 1 }); + + // Both checks if the flow exists and set's it as used so it can only be used once + const res = await getRepository(UploadFlow).update( + { id, date: MoreThan(oneMinAgo), used: false }, + { used: true } + ); + + return !!res.affected; + } + + /** + * Permanently delete all upload flow data for a contact. + * @param contact The contact + */ + async permanentlyDeleteContact(contact: Contact): Promise { + await getRepository(UploadFlow).delete({ contactId: contact.id }); + } + + /** + * Check if the given IP address has exceeded the rate limit for uploads. + * @param ipAddress The IP address + * @param date The date to check from + * @param max The maximum number of uploads allowed + */ + private async canUploadOrFail(ipAddress: string, date: Date, max: number) { + const uploadFlows = await getRepository(UploadFlow).find({ + where: { ipAddress, date: MoreThan(date) } + }); + if (uploadFlows.length >= max) { + throw new HttpError(429, "Too many upload requests"); + } + } +} + +export default new UploadFlowService(); From 1cf04a81e77015067e13ada54c21e224a86341ce Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Fri, 10 May 2024 16:28:45 +0100 Subject: [PATCH 4/8] Rename method for consistency --- src/core/providers/newsletter/MailchimpProvider.ts | 2 +- src/core/providers/newsletter/NoneProvider.ts | 2 +- src/core/providers/newsletter/index.ts | 2 +- src/core/services/NewsletterService.ts | 9 +++++++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/core/providers/newsletter/MailchimpProvider.ts b/src/core/providers/newsletter/MailchimpProvider.ts index 94518d1c3..4d2ea5a6c 100644 --- a/src/core/providers/newsletter/MailchimpProvider.ts +++ b/src/core/providers/newsletter/MailchimpProvider.ts @@ -263,7 +263,7 @@ export default class MailchimpProvider implements NewsletterProvider { await this.dispatchOperations(operations); } - async deleteContacts(emails: string[]): Promise { + async permanentlyDeleteContacts(emails: string[]): Promise { const operations: Operation[] = emails.map((email) => ({ path: this.emailUrl(email) + "/actions/permanently-delete", method: "POST", diff --git a/src/core/providers/newsletter/NoneProvider.ts b/src/core/providers/newsletter/NoneProvider.ts index 764cc61a6..efc787bba 100644 --- a/src/core/providers/newsletter/NoneProvider.ts +++ b/src/core/providers/newsletter/NoneProvider.ts @@ -19,5 +19,5 @@ export default class NoneProvider implements NewsletterProvider { ): Promise {} async upsertContacts(contacts: UpdateNewsletterContact[]): Promise {} async archiveContacts(emails: string[]): Promise {} - async deleteContacts(emails: string[]): Promise {} + async permanentlyDeleteContacts(emails: string[]): Promise {} } diff --git a/src/core/providers/newsletter/index.ts b/src/core/providers/newsletter/index.ts index 30190e350..778f72e55 100644 --- a/src/core/providers/newsletter/index.ts +++ b/src/core/providers/newsletter/index.ts @@ -30,5 +30,5 @@ export interface NewsletterProvider { ): Promise; upsertContacts(contacts: UpdateNewsletterContact[]): Promise; archiveContacts(emails: string[]): Promise; - deleteContacts(emails: string[]): Promise; + permanentlyDeleteContacts(emails: string[]): Promise; } diff --git a/src/core/services/NewsletterService.ts b/src/core/services/NewsletterService.ts index a2c1acf02..67d11bf8e 100644 --- a/src/core/services/NewsletterService.ts +++ b/src/core/services/NewsletterService.ts @@ -140,9 +140,14 @@ class NewsletterService { ); } - async deleteContacts(contacts: Contact[]): Promise { + /** + * Permanently remove contacts from the newsletter provider + * + * @param contacts The contacts to delete + */ + async permanentlyDeleteContacts(contacts: Contact[]): Promise { log.info(`Delete ${contacts.length} contacts`); - await this.provider.deleteContacts( + await this.provider.permanentlyDeleteContacts( (await getValidNlUpdates(contacts)).map((m) => m.email) ); } From 7e0f2794a0189b28231875b51825095a5b1b3c8e Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Fri, 10 May 2024 16:29:01 +0100 Subject: [PATCH 5/8] Implement delete contact for PaymentService --- src/core/providers/payment/ManualProvider.ts | 4 +--- src/core/providers/payment/StripeProvider.ts | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/providers/payment/ManualProvider.ts b/src/core/providers/payment/ManualProvider.ts index 7fc4ef1b5..c219da22c 100644 --- a/src/core/providers/payment/ManualProvider.ts +++ b/src/core/providers/payment/ManualProvider.ts @@ -35,7 +35,5 @@ export default class ManualProvider extends PaymentProvider { ): Promise { throw new Error("Method not implemented."); } - async permanentlyDeleteContact(): Promise { - throw new Error("Method not implemented."); - } + async permanentlyDeleteContact(): Promise {} } diff --git a/src/core/providers/payment/StripeProvider.ts b/src/core/providers/payment/StripeProvider.ts index ad421ec28..d34c7c975 100644 --- a/src/core/providers/payment/StripeProvider.ts +++ b/src/core/providers/payment/StripeProvider.ts @@ -224,6 +224,8 @@ export default class StripeProvider extends PaymentProvider { } async permanentlyDeleteContact(): Promise { - throw new Error("Method not implemented."); + if (this.data.customerId) { + await stripe.customers.del(this.data.customerId); + } } } From 0d7a164a8533bb6214f00ddd94bb4b21014ff091 Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Fri, 10 May 2024 16:31:39 +0100 Subject: [PATCH 6/8] Implement permanently delete methods for lots of services --- src/core/services/CalloutsService.ts | 16 ++++++++++++++++ src/core/services/ContactMfaService.ts | 16 ++++++++++------ src/core/services/PaymentService.ts | 20 +++++++++++++++++--- src/core/services/ReferralsService.ts | 5 +++++ src/core/services/SegmentService.ts | 9 +++++++++ 5 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/core/services/CalloutsService.ts b/src/core/services/CalloutsService.ts index 4ba447a80..0db63652d 100644 --- a/src/core/services/CalloutsService.ts +++ b/src/core/services/CalloutsService.ts @@ -298,6 +298,22 @@ class CalloutsService { return savedResponse; } + /** + * Permanently delete or disassociate a contact's callout data + * @param contact + */ + public async permanentlyDeleteContact(contact: Contact): Promise { + log.info("Permanently delete callout data for contact " + contact.id); + + await getRepository(CalloutResponseComment).delete({ + contactId: contact.id + }); + await getRepository(CalloutResponse).update( + { contactId: contact.id }, + { contactId: null } + ); + } + /** * Saves a callout and it's variants, handling duplicate slug errors * @param data diff --git a/src/core/services/ContactMfaService.ts b/src/core/services/ContactMfaService.ts index a680ebbae..2e1d8b652 100644 --- a/src/core/services/ContactMfaService.ts +++ b/src/core/services/ContactMfaService.ts @@ -127,6 +127,14 @@ class ContactMfaService { return validateTotpToken(mfa.secret, token, window); } + /** + * Permanently delete the MFA data for a contact + * @param contact The contact + */ + async permanentlyDeleteContact(contact: Contact): Promise { + await getRepository(ContactMfa).delete({ contactId: contact.id }); + } + /** * Get contact MFA by contact. * @@ -138,12 +146,8 @@ class ContactMfaService { * @returns The **insecure** contact MFA with the `secret` key */ private async getInsecure(contact: Contact): Promise { - const mfa = await getRepository(ContactMfa).findOne({ - where: { - contact: { - id: contact.id - } - } + const mfa = await getRepository(ContactMfa).findOneBy({ + contactId: contact.id }); return mfa || null; } diff --git a/src/core/services/PaymentService.ts b/src/core/services/PaymentService.ts index 7af457c4c..b77663a4d 100644 --- a/src/core/services/PaymentService.ts +++ b/src/core/services/PaymentService.ts @@ -1,6 +1,6 @@ import { MembershipStatus, PaymentMethod } from "@beabee/beabee-common"; -import { getRepository } from "@core/database"; +import { getRepository, runTransaction } from "@core/database"; import { log as mainLogger } from "@core/logging"; import { PaymentForm } from "@core/utils"; import { calcRenewalDate } from "@core/utils/payment"; @@ -203,10 +203,24 @@ class PaymentService { ); } + /** + * Permanently delete or disassociate all payment related data for a contact. + * This will also cancel any active contributions + * + * @param contact The contact + */ async permanentlyDeleteContact(contact: Contact): Promise { + log.info("Permanently delete payment data for contact " + contact.id); await this.provider(contact, (p) => p.permanentlyDeleteContact()); - await getRepository(ContactContribution).delete({ contactId: contact.id }); - await getRepository(Payment).delete({ contactId: contact.id }); + + await runTransaction(async (em) => { + await em + .getRepository(ContactContribution) + .delete({ contactId: contact.id }); + await em + .getRepository(Payment) + .update({ contactId: contact.id }, { contactId: null }); + }); } } diff --git a/src/core/services/ReferralsService.ts b/src/core/services/ReferralsService.ts index 75c84c096..6d636ffde 100644 --- a/src/core/services/ReferralsService.ts +++ b/src/core/services/ReferralsService.ts @@ -132,7 +132,12 @@ export default class ReferralsService { return false; } + /** + * Permanently unlink all a contact's referrals + * @param contact The contact + */ static async permanentlyDeleteContact(contact: Contact): Promise { + log.info("Permanently delete contact referrals for contact " + contact.id); await getRepository(Referral).update( { referrerId: contact.id }, { referrer: null } diff --git a/src/core/services/SegmentService.ts b/src/core/services/SegmentService.ts index cbc800547..914f1bc9b 100644 --- a/src/core/services/SegmentService.ts +++ b/src/core/services/SegmentService.ts @@ -7,6 +7,7 @@ import { buildSelectQuery } from "@api/utils/rules"; import Contact from "@models/Contact"; import Segment from "@models/Segment"; +import SegmentContact from "@models/SegmentContact"; import { AuthInfo } from "@type/auth-info"; @@ -63,6 +64,14 @@ class SegmentService { ): Promise { await getRepository(Segment).update(segmentId, updates); } + + /** + * Permanently delete a contact's segment related data + * @param contact The contact + */ + async permanentlyDeleteContact(contact: Contact): Promise { + await getRepository(SegmentContact).delete({ contactId: contact.id }); + } } export default new SegmentService(); From 8198df1cd834b4776e1fd5a2e85cf8d1cd71657a Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Fri, 10 May 2024 16:33:59 +0100 Subject: [PATCH 7/8] Implement new permanently delete method for contacts --- src/apps/members/apps/member/app.ts | 5 --- src/core/services/ContactsService.ts | 58 ++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/apps/members/apps/member/app.ts b/src/apps/members/apps/member/app.ts index 3dc02abd8..0ed44e013 100644 --- a/src/apps/members/apps/member/app.ts +++ b/src/apps/members/apps/member/app.ts @@ -123,11 +123,6 @@ app.post( req.flash("success", "member-password-reset-generated"); break; case "permanently-delete": - // TODO: anonymise data in callout answers - - await ReferralsService.permanentlyDeleteContact(contact); - await PaymentService.permanentlyDeleteContact(contact); - await ContactsService.permanentlyDeleteContact(contact); req.flash("success", "member-permanently-deleted"); diff --git a/src/core/services/ContactsService.ts b/src/core/services/ContactsService.ts index d19d061c3..bcf603ddd 100644 --- a/src/core/services/ContactsService.ts +++ b/src/core/services/ContactsService.ts @@ -6,23 +6,35 @@ import { } from "@beabee/beabee-common"; import { FindManyOptions, FindOneOptions, FindOptionsWhere, In } from "typeorm"; -import { createQueryBuilder, getRepository } from "@core/database"; +import { + createQueryBuilder, + getRepository, + runTransaction +} from "@core/database"; import { log as mainLogger } from "@core/logging"; import { cleanEmailAddress, isDuplicateIndex, PaymentForm } from "@core/utils"; import { generatePassword, isValidPassword } from "@core/utils/auth"; import { generateContactCode } from "@core/utils/contact"; +import ApiKeyService from "@core/services/ApiKeyService"; +import CalloutsService from "@core/services/CalloutsService"; import ContactMfaService from "@core/services/ContactMfaService"; import EmailService from "@core/services/EmailService"; import NewsletterService from "@core/services/NewsletterService"; import OptionsService from "@core/services/OptionsService"; import PaymentService from "@core/services/PaymentService"; +import ReferralsService from "@core/services/ReferralsService"; import ResetSecurityFlowService from "@core/services/ResetSecurityFlowService"; +import SegmentService from "@core/services/SegmentService"; +import UploadFlowService from "@core/services/UploadFlowService"; import Contact from "@models/Contact"; import ContactProfile from "@models/ContactProfile"; import ContactRole from "@models/ContactRole"; +import GiftFlow from "@models/GiftFlow"; import Password from "@models/Password"; +import Project from "@models/Project"; +import ProjectEngagement from "@models/ProjectEngagement"; import BadRequestError from "@api/errors/BadRequestError"; import CantUpdateContribution from "@api/errors/CantUpdateContribution"; @@ -366,9 +378,49 @@ class ContactsService { }); } + /** + * Permanently delete a contact and all associated data. + * + * @param contact The contact + */ async permanentlyDeleteContact(contact: Contact): Promise { - await getRepository(Contact).delete(contact.id); - await NewsletterService.deleteContacts([contact]); + // Delete external data first, this is more likely to fail so we'd exit the process early + await NewsletterService.permanentlyDeleteContacts([contact]); + await PaymentService.permanentlyDeleteContact(contact); + + // Delete internal data after the external services are done, this should really never fail + await ResetSecurityFlowService.deleteAll(contact); + await ApiKeyService.permanentlyDeleteContact(contact); + await ReferralsService.permanentlyDeleteContact(contact); + await UploadFlowService.permanentlyDeleteContact(contact); + await SegmentService.permanentlyDeleteContact(contact); + await CalloutsService.permanentlyDeleteContact(contact); + await ContactMfaService.permanentlyDeleteContact(contact); + + log.info("Permanently delete contact " + contact.id); + await runTransaction(async (em) => { + // Projects are only in the legacy app, so really no one should have any, but we'll delete them just in case + // TODO: Remove this when we've reworked projects + await em + .getRepository(ProjectEngagement) + .delete({ byContactId: contact.id }); + await em + .getRepository(ProjectEngagement) + .delete({ toContactId: contact.id }); + await em + .getRepository(Project) + .update({ ownerId: contact.id }, { ownerId: null }); + + // Gifts are only in the legacy app, so really no one should have any, but we'll delete them just in case + // TODO: Remove this when we've reworked gifts + await em + .getRepository(GiftFlow) + .update({ gifteeId: contact.id }, { gifteeId: null }); + + await em.getRepository(ContactRole).delete({ contactId: contact.id }); + await em.getRepository(ContactProfile).delete({ contactId: contact.id }); + await em.getRepository(Contact).delete(contact.id); + }); } /** From 3d8671e73c3b3e77be24d0809d53e6a6b860e7eb Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Fri, 10 May 2024 16:41:04 +0100 Subject: [PATCH 8/8] Add new endpoint --- src/api/controllers/ContactController.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/api/controllers/ContactController.ts b/src/api/controllers/ContactController.ts index 0ad786a45..6d766c37a 100644 --- a/src/api/controllers/ContactController.ts +++ b/src/api/controllers/ContactController.ts @@ -191,6 +191,12 @@ export class ContactController { }); } + @Delete("/:id") + @OnUndefined(204) + async deleteContact(@TargetUser() target: Contact): Promise { + await ContactsService.permanentlyDeleteContact(target); + } + @Get("/:id/contribution") async getContribution( @TargetUser() target: Contact