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

feat: contact tags #283

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/api/controllers/ContactController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export class ContactController {
})
},
data.profile && {
...data.profile,
// ...data.profile,
...(data.profile.newsletterStatus === NewsletterStatus.Subscribed && {
// Automatically add default groups for now, this should be revisited
// once groups are exposed to the frontend
Expand Down Expand Up @@ -230,7 +230,7 @@ export class ContactController {
throw new UnauthorizedError();
}

await ContactsService.updateContactProfile(target, data.profile);
// await ContactsService.updateContactProfile(target, data.profile);
}

return await this.getContact(caller, target, {
Expand Down
2 changes: 1 addition & 1 deletion src/api/data/CalloutResponseData/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ const tagsFieldHandler: FieldHandler = (qb, args) => {
}

const inOp =
args.operator === "not_contains" || args.operator === "is_not_empty"
args.operator === "not_contains" || args.operator === "is_empty"
? "NOT IN"
: "IN";

Expand Down
46 changes: 42 additions & 4 deletions src/api/data/ContactData/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getMembershipStatus } from "@core/services/PaymentService";
import Contact from "@models/Contact";
import ContactRole from "@models/ContactRole";
import ContactProfile from "@models/ContactProfile";
import ContactProfileTag from "@models/ContactProfileTag";
import PaymentData from "@models/PaymentData";

import {
Expand All @@ -17,6 +18,7 @@ import {
GetPaginatedRuleGroup
} from "@api/data/PaginatedData";

import { convertTagToData } from "../ContactTagData";
import { GetContactData, GetContactsQuery, GetContactWith } from "./interface";

interface ConvertOpts {
Expand Down Expand Up @@ -60,7 +62,7 @@ export function convertContactToData(
newsletterStatus: contact.profile.newsletterStatus,
newsletterGroups: contact.profile.newsletterGroups,
...(opts.withRestricted && {
tags: contact.profile.tags,
tags: contact.profile.tags.map((t) => convertTagToData(t.tag)),
notes: contact.profile.notes,
description: contact.profile.description
})
Expand Down Expand Up @@ -142,10 +144,27 @@ function paymentDataField(field: string): FieldHandler {
};
}

const tagsFieldHandler: FieldHandler = (qb, args) => {
const subQb = createQueryBuilder()
.subQuery()
.select("cpt.profileContact")
.from(ContactProfileTag, "cpt");

if (args.operator === "contains" || args.operator === "not_contains") {
subQb.where(args.suffixFn("cpt.tag = :a"));
}

const inOp =
args.operator === "not_contains" || args.operator === "is_empty"
? "NOT IN"
: "IN";

qb.where(`${args.fieldPrefix}id ${inOp} ${subQb.getQuery()}`);
};

export const contactFieldHandlers: FieldHandlers<ContactFilterName> = {
deliveryOptIn: profileField("deliveryOptIn"),
newsletterStatus: profileField("newsletterStatus"),
tags: profileField("tags"),
activePermission,
activeMembership: activePermission,
membershipStarts: membershipField("dateAdded"),
Expand All @@ -156,7 +175,8 @@ export const contactFieldHandlers: FieldHandlers<ContactFilterName> = {
manualPaymentSource: (qb, args) => {
paymentDataField("pd.data ->> 'source'")(qb, args);
qb.andWhere(`${args.fieldPrefix}contributionType = 'Manual'`);
}
},
tags: tagsFieldHandler
};

export async function exportContacts(
Expand All @@ -174,6 +194,8 @@ export async function exportContacts(
qb.orderBy(`${fieldPrefix}joined`);
qb.leftJoinAndSelect(`${fieldPrefix}roles`, "roles");
qb.leftJoinAndSelect(`${fieldPrefix}profile`, "profile");
qb.leftJoinAndSelect("profile.tags", "tags");
qb.leftJoinAndSelect("tags.tag", "tag");
qb.leftJoinAndSelect(`${fieldPrefix}paymentData`, "pd");
}
);
Expand All @@ -189,7 +211,7 @@ export async function exportContacts(
FirstName: contact.firstname,
LastName: contact.lastname,
Joined: contact.joined,
Tags: contact.profile.tags.join(", "),
Tags: contact.profile.tags.map((t) => t.tag.name).join(", "),
ContributionType: contact.contributionType,
ContributionMonthlyAmount: contact.contributionMonthlyAmount,
ContributionPeriod: contact.contributionPeriod,
Expand Down Expand Up @@ -266,6 +288,22 @@ export async function fetchPaginatedContacts(
// Load roles after to ensure offset/limit work
await loadContactRoles(results.items);

if (query.with?.includes(GetContactWith.Profile)) {
const ids = results.items.map((t) => t.id);
// Load tags after to ensure offset/limit work
const profileTags = await createQueryBuilder(ContactProfileTag, "cpt")
.where("cpt.profile IN (:...ids)", { ids })
.innerJoinAndSelect("cpt.tag", "tag")
.loadAllRelationIds({ relations: ["profile"] })
.getMany();

for (const item of results.items) {
item.profile.tags = profileTags.filter(
(pt) => (pt as any).profile === item.id
);
}
}

return {
...results,
items: results.items.map((item) =>
Expand Down
9 changes: 5 additions & 4 deletions src/api/data/ContactData/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ import Address from "@models/Address";

import { GetPaginatedQuery } from "@api/data/PaginatedData";
import { ForceUpdateContributionData } from "../ContributionData";
import { GetContactTagData } from "../ContactTagData";

interface ContactData {
email: string;
firstname: string;
lastname: string;
}

interface ContactProfileData {
interface GetContactProfileData {
telephone: string;
twitter: string;
preferredContact: string;
Expand All @@ -44,7 +45,7 @@ interface ContactProfileData {
newsletterGroups: string[];

// Admin only
tags?: string[];
tags?: GetContactTagData[];
notes?: string;
description?: string;
}
Expand Down Expand Up @@ -79,7 +80,7 @@ export interface GetContactData extends ContactData {
contributionAmount?: number;
contributionPeriod?: ContributionPeriod;
activeRoles: RoleType[];
profile?: ContactProfileData;
profile?: GetContactProfileData;
roles?: GetContactRoleData[];
contribution?: ContributionInfo;
}
Expand Down Expand Up @@ -136,7 +137,7 @@ class UpdateAddressData implements Address {
postcode!: string;
}

class UpdateContactProfileData implements Partial<ContactProfileData> {
class UpdateContactProfileData {
@IsOptional()
@IsString()
telephone?: string;
Expand Down
11 changes: 11 additions & 0 deletions src/api/data/ContactTagData/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import ContactTag from "@models/ContactTag";
import { GetContactTagData } from "./interface";

export function convertTagToData(tag: ContactTag): GetContactTagData {
return {
id: tag.id,
name: tag.name
};
}

export * from "./interface";
14 changes: 14 additions & 0 deletions src/api/data/ContactTagData/interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IsString } from "class-validator";

export interface GetContactTagData {
id: string;
name: string;
}

export class CreateContactTagData {
@IsString()
name!: string;

@IsString()
description!: string;
}
1 change: 0 additions & 1 deletion src/apps/members/apps/member/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ app.post(
switch (req.body.action) {
case "save-about": {
await ContactsService.updateContactProfile(contact, {
tags: req.body.tags || [],
description: req.body.description || "",
bio: req.body.bio || ""
});
Expand Down
19 changes: 2 additions & 17 deletions src/apps/members/apps/member/views/partials/profile.pug
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,6 @@ mixin editablePanel(title, id)
|
button(type='button').btn.btn-default.btn-sm.js-edit-member-toggle Cancel

mixin memberTag(tag)
a(href='/members/?tag=' + encodeURIComponent(tag) data-tag=tag).label.label-info.member-tag.js-edit-member-tag
input(type='hidden' name='tags[]' value=tag)
= tag
span.glyphicon.glyphicon-remove.hidden.js-edit-member-hidden

script(type='text/template').js-edit-member-tag-template
+memberTag('XXX')

.row
.col-md-12
+editablePanel('Contact details', 'contact')
Expand Down Expand Up @@ -101,11 +92,5 @@ script(type='text/template').js-edit-member-tag-template

p
| Tags:
span.js-edit-member-tags
each tag in member.profile.tags
+memberTag(tag)
p.hidden.js-edit-member-hidden.form-inline
select(style='width:30%').form-control.member-add-tag.js-edit-member-add-tag
option(value='' selected disabled) Add tag
each tag in availableTags
option= tag
each tag in member.profile.tags
span.label.label-info= tag.name
14 changes: 7 additions & 7 deletions src/core/services/ContactsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ import ContactRole from "@models/ContactRole";
import DuplicateEmailError from "@api/errors/DuplicateEmailError";
import CantUpdateContribution from "@api/errors/CantUpdateContribution";

export type PartialContact = Pick<Contact, "email" | "contributionType"> &
Partial<Contact>;

interface ForceUpdateContribution {
type: ContributionType.Manual | ContributionType.None;
period?: ContributionPeriod;
Expand Down Expand Up @@ -264,12 +261,15 @@ class ContactsService {
opts = { sync: true }
): Promise<void> {
log.info("Update contact profile for " + contact.id);
await getRepository(ContactProfile).update(contact.id, updates);

if (contact.profile) {
Object.assign(contact.profile, updates);
if (!contact.profile) {
contact.profile = await getRepository(ContactProfile).findOneOrFail({
contact
});
}

Object.assign(contact.profile, updates);
await getRepository(ContactProfile).save(contact.profile);

if (opts.sync && (updates.newsletterStatus || updates.newsletterGroups)) {
await NewsletterService.upsertContact(contact);
}
Expand Down
19 changes: 19 additions & 0 deletions src/migrations/1689950183370-MakeCalloutTagNameUnique.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class MakeCalloutTagNameUnique1689950183370
implements MigrationInterface
{
name = "MakeCalloutTagNameUnique1689950183370";

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "callout_tag" ADD CONSTRAINT "UQ_3e46b5c8434cbfa9f58559ee66b" UNIQUE ("name", "calloutSlug")`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "callout_tag" DROP CONSTRAINT "UQ_3e46b5c8434cbfa9f58559ee66b"`
);
}
}
80 changes: 80 additions & 0 deletions src/migrations/1689950317881-AddContactTag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddContactTag1689950317881 implements MigrationInterface {
name = "AddContactTag1689950317881";

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "contact_tag" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "description" character varying NOT NULL, CONSTRAINT "PK_e46544545a47cff21d83da44cf1" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_4f430fb165d3f0dfdfe5121c7c" ON "contact_tag" ("name") `
);
await queryRunner.query(
`CREATE TABLE "contact_profile_tag" ("date" TIMESTAMP NOT NULL DEFAULT now(), "profileContact" uuid NOT NULL, "tagId" uuid NOT NULL, CONSTRAINT "PK_7af1e3cffcb40c1628d7cc1afb6" PRIMARY KEY ("profileContact", "tagId"))`
);
await queryRunner.query(
`ALTER TABLE "contact_profile_tag" ADD CONSTRAINT "FK_4b15df2e029d612898880c5d914" FOREIGN KEY ("profileContact") REFERENCES "contact_profile"("contactId") ON DELETE NO ACTION ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "contact_profile_tag" ADD CONSTRAINT "FK_5dee04ef2ddece93655fcffc7b7" FOREIGN KEY ("tagId") REFERENCES "contact_tag"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
);

// Convert old tags to new tags

const profiles: { contactId: string; tags: string[] }[] =
await queryRunner.query(
`SELECT "contactId", "tags" FROM contact_profile`
);

const uniqueTags = profiles
.flatMap((p) => p.tags)
.filter((t, i, a) => a.indexOf(t) === i);

const tagIdByName: Record<string, string> = {};
for (const tag of uniqueTags) {
const [{ id }]: { id: string }[] = await queryRunner.query(
`INSERT INTO contact_tag (name, description) VALUES ($1, '') RETURNING id`,
[tag]
);
console.log(id);
tagIdByName[tag] = id;
}

// Add tags to contact_profile_tag

const tagInserts = profiles.flatMap((p) =>
p.tags.map((tagName) => [p.contactId, tagIdByName[tagName]] as const)
);

const placeholders = tagInserts
.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`)
.join(", ");

await queryRunner.query(
`INSERT INTO contact_profile_tag ("profileContact", "tagId") VALUES ${placeholders}`,
tagInserts.flat()
);

// Drop old tags column

await queryRunner.query(`ALTER TABLE "contact_profile" DROP COLUMN "tags"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "contact_profile_tag" DROP CONSTRAINT "FK_5dee04ef2ddece93655fcffc7b7"`
);
await queryRunner.query(
`ALTER TABLE "contact_profile_tag" DROP CONSTRAINT "FK_4b15df2e029d612898880c5d914"`
);
await queryRunner.query(
`ALTER TABLE "contact_profile" ADD "tags" jsonb NOT NULL DEFAULT '[]'`
);
await queryRunner.query(`DROP TABLE "contact_profile_tag"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_4f430fb165d3f0dfdfe5121c7c"`
);
await queryRunner.query(`DROP TABLE "contact_tag"`);
}
}
9 changes: 8 additions & 1 deletion src/models/CalloutTag.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import {
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
Unique
} from "typeorm";
import Callout from "./Callout";

@Entity()
@Unique(["name", "callout"])
export default class CalloutTag {
@PrimaryGeneratedColumn("uuid")
id!: string;
Expand Down
Loading