Skip to content
Open
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
30 changes: 30 additions & 0 deletions apps/backend/src/api/controllers/ContactController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Contact } from '@beabee/core/models';
import ContactMfaService from '@beabee/core/services/ContactMfaService';
import ContactsService from '@beabee/core/services/ContactsService';
import DispatchService from '@beabee/core/services/DispatchService';
import NewsletterService from '@beabee/core/services/NewsletterService';
import PaymentFlowService from '@beabee/core/services/PaymentFlowService';
import PaymentService from '@beabee/core/services/PaymentService';
import { AuthInfo } from '@beabee/core/type';
Expand All @@ -36,6 +37,7 @@ import {
import { CurrentAuth } from '#api/decorators/CurrentAuth';
import PartialBody from '#api/decorators/PartialBody';
import { TargetUser } from '#api/decorators/TargetUser';
import { GetContactNewsletterGroupDto } from '#api/dto';
import { GetExportQuery } from '#api/dto/BaseDto';
import {
BatchUpdateContactDto,
Expand Down Expand Up @@ -500,4 +502,32 @@ export class ContactController {
throw new NotFoundError();
}
}

/**
* Get the newsletter groups a contact is currently subscribed to
* @param target The target contact
*/
@Get('/:id/newsletter-groups')
async getNewsletterGroups(
@TargetUser() target: Contact
): Promise<GetContactNewsletterGroupDto[]> {
const groups = await NewsletterService.getContactNewsletterGroups(
target.id
);
return plainToInstance(GetContactNewsletterGroupDto, groups);
}

/**
* Unsubscribe a contact from a single newsletter group
* @param target The target contact
* @param groupId The newsletter group ID
*/
@OnUndefined(204)
@Delete('/:id/newsletter-groups/:groupId')
async unsubscribeNewsletterGroup(
@TargetUser() target: Contact,
@Params() { groupId }: { groupId: string }
): Promise<void> {
await NewsletterService.unsubscribeFromNewsletterGroup(target, groupId);
}
}
8 changes: 6 additions & 2 deletions apps/backend/src/api/controllers/DevController.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { RoleType, RoleTypes } from '@beabee/beabee-common';
import {
BaseNewsletterGroupData,
RoleType,
RoleTypes,
} from '@beabee/beabee-common';
import config from '@beabee/core/config';
import { getRepository } from '@beabee/core/database';
import { NotFoundError } from '@beabee/core/errors';
Expand Down Expand Up @@ -49,7 +53,7 @@ export class DevController {
*/
@Post('/newsletter-groups')
async setNewsletterGroups(
@Body({ validate: false }) groups: { id: string; label: string }[]
@Body({ validate: false }) groups: BaseNewsletterGroupData[]
): Promise<{ message: string }> {
if (!config.dev) {
throw new NotFoundError();
Expand Down
13 changes: 12 additions & 1 deletion apps/backend/src/api/dto/NewsletterDto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { NewsletterGroupData } from '@beabee/beabee-common';
import {
BaseNewsletterGroupData,
NewsletterGroupData,
} from '@beabee/beabee-common';

import { IsBoolean, IsString } from 'class-validator';

Expand All @@ -12,3 +15,11 @@ export class NewsletterGroupDto implements NewsletterGroupData {
@IsBoolean()
checked!: boolean;
}

export class GetContactNewsletterGroupDto implements BaseNewsletterGroupData {
@IsString()
id!: string;

@IsString()
label!: string;
}
4 changes: 3 additions & 1 deletion apps/frontend-old/src/type/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Auto-generated by `@beabee/dev-cli generate-index`. Do not edit by hand.
export * from './app-qr-code-error-correction-level';
export * from './app-qr-code-props';
export * from './app-qr-code-type-number';
Expand All @@ -8,6 +9,7 @@ export * from './email-editor';
export * from './formio';
export * from './geocode-pick-event';
export * from './get-callout-response-map-data-with-address';
export * from './integration';
export * from './item-with-status';
export * from './join-form-data';
export * from './locale-prop';
Expand All @@ -17,7 +19,7 @@ export * from './notification';
export * from './paginated';
export * from './payment-flow-form-data';
export * from './search';
export * from './selection-state';
export * from './set-mfa-steps';
export * from './set-mfa-totp-identity';
export * from './selection-state';
export * from './table';
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
<AppCategoryLabel>
{{ t('adminSettings.integrations.newsletter.groups') }}
</AppCategoryLabel>
<AppTable v-if="groups?.length" :headers="groupHeaders" :items="groups">
<template #value-id="{ item }">
<span class="font-mono text-sm">{{ item.id }}</span>
</template>
</AppTable>
<template v-if="groups?.length">
<AppTable :headers="groupHeaders" :items="groups">
<template #value-id="{ item }">
<span class="font-mono text-sm">{{ item.id }}</span>
</template>
</AppTable>
<AppInputHelp
class="mt-3"
:message="t('adminSettings.integrations.newsletter.groupsNote')"
/>
</template>
<p v-else class="text-sm text-body-80">
{{ t('adminSettings.integrations.newsletter.noGroups') }}
</p>
Expand All @@ -17,7 +23,7 @@
</template>

<script lang="ts" setup>
import { AppCategoryLabel, AppTable } from '@beabee/vue';
import { AppCategoryLabel, AppInputHelp, AppTable } from '@beabee/vue';
import type { Header } from '@beabee/vue';
import { useI18n } from 'vue-i18n';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@
*
* @component AppNewsletterOptInSettings
*/
import type { NewsletterGroupData } from '@beabee/beabee-common';
import type {
BaseNewsletterGroupData,
NewsletterGroupData,
} from '@beabee/beabee-common';
import {
AppCheckbox,
AppInput,
Expand Down Expand Up @@ -144,12 +147,9 @@ const text = defineModel<string>('text', { default: '' });
const groups = defineModel<NewsletterGroupData[]>('groups', {
default: () => [],
});
const cachedGroups = defineModel<Omit<NewsletterGroupData, 'checked'>[]>(
'cachedGroups',
{
default: () => [],
}
);
const cachedGroups = defineModel<BaseNewsletterGroupData[]>('cachedGroups', {
default: () => [],
});

onBeforeMount(async () => {
cachedGroups.value = await client.integrations.getNewsletterGroups();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type BaseNewsletterGroupData,
type ContactFilterName,
ContributionPeriod,
ContributionType,
Expand Down Expand Up @@ -182,7 +183,7 @@ const filterItems = computed<FilterItems<ContactFilterName>>(() => ({
* @returns Filter groups and tag items for use in the contact list view
*/
export function useContactFilters() {
const newsletterGroups = ref<{ id: string; label: string }[]>([]);
const newsletterGroups = ref<BaseNewsletterGroupData[]>([]);
(async () => {
const data = await client.integrations.getNewsletter();
newsletterGroups.value = data.provider !== 'none' ? data.groups : [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
<UForm
id="account-form"
ref="formRef"
class="flex flex-col gap-6"
class="flex flex-col gap-4"
:schema="schema"
:state="data"
@submit="handleSave"
>
<AppSectionCard
icon="i-lucide-user-round"
:title="t('accountPage.contactInformation')"
:title="t('accountPage.personalDetails')"
>
<AppFormSkeleton v-if="loading" :rows="3" />
<template v-else>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<!--
# AccountNewsletterSubscriptions
Member-only ("me") view of the newsletter groups they belong to, with the
ability to unsubscribe from individual groups. Joining new groups isn't
supported here (managed externally, e.g. Mailchimp).
-->
<template>
<AppSectionCard
icon="i-lucide-mail"
:title="t('accountPage.newsletter.title')"
:description="t('accountPage.newsletter.description')"
>
<AppFormSkeleton v-if="loading" :rows="2" />
<template v-else>
<p v-if="groups.length === 0" class="text-muted">
{{ t('accountPage.newsletter.noGroups') }}
</p>
<UAlert
v-else
icon="i-lucide-info"
color="neutral"
variant="subtle"
:description="t('accountPage.newsletter.unsubscribeInfo')"
/>
<div
v-for="group in groups"
:key="group.id"
class="flex items-center justify-between gap-4"
>
<p class="font-medium">{{ group.label }}</p>
<UButton
variant="outline"
color="neutral"
size="xs"
:loading="removingId === group.id"
@click="() => handleUnsubscribe(group)"
>
{{ t('accountPage.newsletter.unsubscribeButton') }}
</UButton>
</div>
</template>
</AppSectionCard>
</template>

<script lang="ts" setup>
import type { BaseNewsletterGroupData } from '@beabee/beabee-common';
import { AppFormSkeleton, AppSectionCard } from '@beabee/vue';

import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';

import { useApiSubmit } from '#composables/useApiSubmit';
import { client } from '#utils/api';

const { t } = useI18n();

const loading = ref(true);
const removingId = ref<string | null>(null);
const groups = ref<BaseNewsletterGroupData[]>([]);

onMounted(async () => {
groups.value = await client.contact.newsletter.getGroups('me');
loading.value = false;
});

async function handleUnsubscribe(group: BaseNewsletterGroupData) {
removingId.value = group.id;
const { submit } = useApiSubmit(
async () => {
await client.contact.newsletter.unsubscribe('me', group.id);
groups.value = groups.value.filter((g) => g.id !== group.id);
},
{
successMessage: () =>
t('accountPage.newsletter.unsubscribedNotification', {
group: group.label,
}),
}
);
await submit();
removingId.value = null;
}
</script>
39 changes: 35 additions & 4 deletions apps/frontend/src/pages/profile/account.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,25 @@ meta:
>
<UIcon
name="i-lucide-shield"
class="text-primary mt-0.5 size-4 shrink-0"
class="text-primary mt-0.5 size-5 shrink-0"
/>
<p>{{ t('accountPage.subTitle-nuxt') }}</p>
</div>

<AccountForm />
<ChangePassword />
<SetMFA contact-id="me" />
<UTabs :items="items" class="w-full">
<template #contact>
<AccountForm />
</template>
<template #security>
<div class="flex flex-col gap-4">
<ChangePassword />
<SetMFA contact-id="me" />
</div>
</template>
<template #subscriptions>
<AccountNewsletterSubscriptions />
</template>
</UTabs>

<p class="text-muted px-1">
<span class="text-primary font-medium">*</span>
Expand All @@ -31,13 +42,33 @@ meta:
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import AccountForm from '../../components/pages/profile/account/AccountForm.vue';
import AccountNewsletterSubscriptions from '../../components/pages/profile/account/AccountNewsletterSubscriptions.vue';
import ChangePassword from '../../components/pages/profile/account/ChangePassword.vue';
import SetMFA from '../../components/pages/profile/account/SetMFA.vue';
import { addBreadcrumb } from '../../store/breadcrumb';
import { routeIcons, routeLabels } from '../../utils/route-nav';
import type { TabsItem } from '@nuxt/ui';

const { t } = useI18n();

const items = computed<TabsItem[]>(() => [
{
label: t('accountPage.contactInformation'),
icon: 'i-lucide-contact',
slot: 'contact',
},
{
label: t('accountPage.security'),
icon: 'i-lucide-shield',
slot: 'security',
},
{
label: t('accountPage.subscriptions'),
icon: 'i-lucide-mail',
slot: 'subscriptions',
},
]);

addBreadcrumb(
computed(() => [
{
Expand Down
38 changes: 38 additions & 0 deletions packages/client/src/api/contact-newsletter.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { BaseNewsletterGroupData } from '@beabee/beabee-common';

import type { BaseClientOptions } from '../types/index.js';
import { cleanUrl } from '../utils/index.js';
import { BaseClient } from './base.client.js';

/**
* Client for managing contact newsletter group operations.
*/
export class ContactNewsletterClient extends BaseClient {
constructor(protected override readonly options: BaseClientOptions) {
super({
...options,
path: cleanUrl(options.path + '/contact'),
});
}

/**
* Get the newsletter groups a contact is currently subscribed to.
* @param contactId - The ID of the contact.
* @returns The groups the contact is subscribed to.
*/
async getGroups(contactId: string): Promise<BaseNewsletterGroupData[]> {
const { data } = await this.fetch.get<BaseNewsletterGroupData[]>(
`/${contactId}/newsletter-groups`
);
return data;
}

/**
* Unsubscribe a contact from a single newsletter group.
* @param contactId - The ID of the contact.
* @param groupId - The ID of the newsletter group to unsubscribe from.
*/
async unsubscribe(contactId: string, groupId: string): Promise<void> {
await this.fetch.delete(`/${contactId}/newsletter-groups/${groupId}`);
}
}
Loading
Loading