Skip to content
This repository has been archived by the owner on Aug 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #422 from beabee-communityrm/feat/delete-contact
Browse files Browse the repository at this point in the history
feat: add delete contact endpoint
  • Loading branch information
wpf500 authored May 16, 2024
2 parents 3597c82 + 3d8671e commit 497f600
Show file tree
Hide file tree
Showing 24 changed files with 345 additions and 86 deletions.
25 changes: 10 additions & 15 deletions src/api/controllers/ApiKeyController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Expand Down Expand Up @@ -56,23 +54,20 @@ export class ApiKeyController {
@CurrentUser({ required: true }) creator: Contact,
@Body() data: CreateApiKeyDto
): Promise<NewApiKeyDto> {
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 });
}

@OnUndefined(204)
@Delete("/:id")
async deleteApiKey(@Param("id") id: string): Promise<void> {
const result = await getRepository(ApiKey).delete(id);
if (!result.affected) throw new NotFoundError();
if (!(await ApiKeyService.delete(id))) {
throw new NotFoundError();
}
}
}
6 changes: 6 additions & 0 deletions src/api/controllers/ContactController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ export class ContactController {
});
}

@Delete("/:id")
@OnUndefined(204)
async deleteContact(@TargetUser() target: Contact): Promise<void> {
await ContactsService.permanentlyDeleteContact(target);
}

@Get("/:id/contribution")
async getContribution(
@TargetUser() target: Contact
Expand Down
43 changes: 4 additions & 39 deletions src/api/controllers/UploadController.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,24 @@
import { plainToInstance } from "class-transformer";
import { sub } from "date-fns";
import { Request } from "express";
import {
CurrentUser,
Get,
HttpError,
JsonController,
NotFoundError,
OnUndefined,
Params,
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("/")
Expand All @@ -43,38 +30,16 @@ 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
// auth_request directive to call this endpoint and it only does GET requests
@Get("/:id")
@OnUndefined(204)
async get(@Params() { id }: UUIDParams): Promise<void> {
// 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();
}
}
Expand Down
5 changes: 0 additions & 5 deletions src/apps/members/apps/member/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion src/core/providers/newsletter/MailchimpProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export default class MailchimpProvider implements NewsletterProvider {
await this.dispatchOperations(operations);
}

async deleteContacts(emails: string[]): Promise<void> {
async permanentlyDeleteContacts(emails: string[]): Promise<void> {
const operations: Operation[] = emails.map((email) => ({
path: this.emailUrl(email) + "/actions/permanently-delete",
method: "POST",
Expand Down
2 changes: 1 addition & 1 deletion src/core/providers/newsletter/NoneProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ export default class NoneProvider implements NewsletterProvider {
): Promise<void> {}
async upsertContacts(contacts: UpdateNewsletterContact[]): Promise<void> {}
async archiveContacts(emails: string[]): Promise<void> {}
async deleteContacts(emails: string[]): Promise<void> {}
async permanentlyDeleteContacts(emails: string[]): Promise<void> {}
}
2 changes: 1 addition & 1 deletion src/core/providers/newsletter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ export interface NewsletterProvider {
): Promise<void>;
upsertContacts(contacts: UpdateNewsletterContact[]): Promise<void>;
archiveContacts(emails: string[]): Promise<void>;
deleteContacts(emails: string[]): Promise<void>;
permanentlyDeleteContacts(emails: string[]): Promise<void>;
}
4 changes: 1 addition & 3 deletions src/core/providers/payment/ManualProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,5 @@ export default class ManualProvider extends PaymentProvider {
): Promise<void> {
throw new Error("Method not implemented.");
}
async permanentlyDeleteContact(): Promise<void> {
throw new Error("Method not implemented.");
}
async permanentlyDeleteContact(): Promise<void> {}
}
4 changes: 3 additions & 1 deletion src/core/providers/payment/StripeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ export default class StripeProvider extends PaymentProvider {
}

async permanentlyDeleteContact(): Promise<void> {
throw new Error("Method not implemented.");
if (this.data.customerId) {
await stripe.customers.del(this.data.customerId);
}
}
}
55 changes: 55 additions & 0 deletions src/core/services/ApiKeyService.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<boolean> {
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();
16 changes: 16 additions & 0 deletions src/core/services/CalloutsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,22 @@ class CalloutsService {
return savedResponse;
}

/**
* Permanently delete or disassociate a contact's callout data
* @param contact
*/
public async permanentlyDeleteContact(contact: Contact): Promise<void> {
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
Expand Down
16 changes: 10 additions & 6 deletions src/core/services/ContactMfaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await getRepository(ContactMfa).delete({ contactId: contact.id });
}

/**
* Get contact MFA by contact.
*
Expand All @@ -138,12 +146,8 @@ class ContactMfaService {
* @returns The **insecure** contact MFA with the `secret` key
*/
private async getInsecure(contact: Contact): Promise<ContactMfa | null> {
const mfa = await getRepository(ContactMfa).findOne({
where: {
contact: {
id: contact.id
}
}
const mfa = await getRepository(ContactMfa).findOneBy({
contactId: contact.id
});
return mfa || null;
}
Expand Down
58 changes: 55 additions & 3 deletions src/core/services/ContactsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } 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";
Expand Down Expand Up @@ -368,9 +380,49 @@ class ContactsService {
});
}

/**
* Permanently delete a contact and all associated data.
*
* @param contact The contact
*/
async permanentlyDeleteContact(contact: Contact): Promise<void> {
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);
});
}

/**
Expand Down
Loading

0 comments on commit 497f600

Please sign in to comment.