feat: add release digest controls
All checks were successful
deploy-socialize / image (push) Successful in 1m13s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-08 08:30:47 -04:00
parent 0b7edb1b7f
commit c527011646
23 changed files with 3085 additions and 25 deletions

View File

@@ -132,6 +132,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/developer/release-update-email-digests/force": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersForceDeveloperReleaseUpdateDigestEmailsHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-updates/{id}": {
parameters: {
query?: never;
@@ -580,6 +596,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/users/preferred-language": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesIdentityHandlersChangePreferredLanguageHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/confirm-email-change": {
parameters: {
query?: never;
@@ -1493,6 +1525,10 @@ export interface components {
titleFr: string;
descriptionFr: string;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto: {
/** Format: int32 */
sentCount?: number;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
/** Format: int32 */
unreadCount?: number;
@@ -1683,6 +1719,9 @@ export interface components {
/** Format: binary */
file: string;
};
SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest: {
preferredLanguage?: string;
};
SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: {
message?: string;
};
@@ -1708,6 +1747,7 @@ export interface components {
/** Format: date-time */
birthDate?: string | null;
address?: string | null;
preferredLanguage?: string;
};
SystemIOStream: components["schemas"]["SystemMarshalByRefObject"] & {
canTimeout?: boolean;
@@ -2715,6 +2755,40 @@ export interface operations {
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersForceDeveloperReleaseUpdateDigestEmailsHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler: {
parameters: {
query?: never;
@@ -3789,6 +3863,44 @@ export interface operations {
};
};
};
SocializeApiModulesIdentityHandlersChangePreferredLanguageHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler: {
parameters: {
query: {

View File

@@ -18,6 +18,7 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
const isLoading = ref(false);
const isSaving = ref(false);
const isRefreshingCommits = ref(false);
const isForcingDigestEmails = ref(false);
const error = ref(null);
const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0);
@@ -142,6 +143,16 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
}
}
async function forceDigestEmails() {
isForcingDigestEmails.value = true;
try {
const response = await client.post('/api/developer/release-update-email-digests/force');
return response.data;
} finally {
isForcingDigestEmails.value = false;
}
}
async function linkCommit(sha, releaseUpdateId) {
await client.post(`/api/developer/release-commits/${sha}/link`, { releaseUpdateId });
await loadCommits();
@@ -202,6 +213,7 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
isLoading,
isSaving,
isRefreshingCommits,
isForcingDigestEmails,
error,
loadUserUpdates,
loadUnreadSummary,
@@ -214,6 +226,7 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
archiveUpdate,
loadCommits,
refreshCommits,
forceDigestEmails,
linkCommit,
linkCommitsToUpdate,
linkFirstReleaseCommits,

View File

@@ -397,12 +397,6 @@
v-else
class="updates-panel"
>
<div class="panel-header">
<div>
<h2>{{ t('releaseCommunications.developer.pastReleases') }}</h2>
<p>{{ t('releaseCommunications.developer.pastReleasesDescription') }}</p>
</div>
</div>
<button
v-for="update in store.developerUpdates"
:key="update.id"
@@ -747,7 +741,7 @@
.update-row {
display: grid;
width: 100%;
gap: 3px;
gap: 8px;
border: 0;
border-bottom: 1px solid #e2e8f0;
background: transparent;

View File

@@ -3,12 +3,14 @@ import {defineStore} from 'pinia'
import {useAuthStore} from "@/features/auth/stores/authStore.js";
import {useClient} from "@/plugins/api.js";
import {useSessionStorage} from "@vueuse/core";
import {useLanguageStore} from "@/stores/languageStore.js";
export const useUserProfileStore = defineStore(
'user-profile',
() => {
const authStore = useAuthStore()
const languageStore = useLanguageStore()
const isUpdating = ref(false)
const isUploadingPortrait = ref(false)
const isLoadingCalendarFeed = ref(false)
@@ -72,6 +74,7 @@ export const useUserProfileStore = defineStore(
const client = useClient()
const userResponse = await client.get("/api/users/profile");
value.value = userResponse.data
languageStore.setLocale(userResponse.data?.preferredLanguage ?? 'en')
} catch (fetchError) {
console.error(fetchError)
}
@@ -170,6 +173,28 @@ export const useUserProfileStore = defineStore(
}
}
async function changePreferredLanguage(preferredLanguage) {
isUpdating.value = true
error.value = null
try {
const client = useClient()
await client.post(
`/api/users/preferred-language`,
{
preferredLanguage: preferredLanguage
})
value.value.preferredLanguage = preferredLanguage;
languageStore.setLocale(preferredLanguage);
} catch (updateError) {
console.error(updateError)
error.value = 'Failed to update profile.'
throw updateError
} finally {
isUpdating.value = false
}
}
async function changeAddress(address) {
try {
const client = useClient()
@@ -278,6 +303,7 @@ export const useUserProfileStore = defineStore(
changeBirthday,
changePhone,
changeEmail,
changePreferredLanguage,
changeAddress,
changePortrait,
fetchCalendarExportFeed,

View File

@@ -19,12 +19,17 @@
lastname: '',
alias: '',
email: '',
preferredLanguage: 'en',
});
const email = computed(() => userProfileStore.user?.email || t('userSettings.noEmail'));
const alias = computed(() => userProfileStore.alias);
const fullname = computed(() => userProfileStore.fullname);
const canSave = computed(() => Boolean(form.email.trim()) && !userProfileStore.isUpdating);
const languageOptions = computed(() => [
{ title: t('releaseCommunications.english'), value: 'en' },
{ title: t('releaseCommunications.french'), value: 'fr' },
]);
const calendarFeedUrl = computed(() => {
const feedUrl = userProfileStore.calendarExportFeed?.feedUrl;
@@ -42,6 +47,7 @@
form.lastname = user?.lastname ?? '';
form.alias = user?.alias ?? '';
form.email = user?.email ?? '';
form.preferredLanguage = user?.preferredLanguage ?? 'en';
}
async function submitSettings() {
@@ -56,6 +62,7 @@
const nextLastname = form.lastname.trim();
const nextAlias = form.alias.trim();
const nextEmail = form.email.trim();
const nextPreferredLanguage = form.preferredLanguage;
settingsError.value = null;
settingsStatus.value = null;
@@ -69,6 +76,10 @@
await userProfileStore.changeAlias(nextAlias || null);
}
if (nextPreferredLanguage !== (user.preferredLanguage ?? 'en')) {
await userProfileStore.changePreferredLanguage(nextPreferredLanguage);
}
let emailChangeRequested = false;
if (nextEmail !== (user.email ?? '')) {
await userProfileStore.changeEmail(nextEmail);
@@ -248,6 +259,15 @@
variant="outlined"
hide-details
/>
<v-select
v-model="form.preferredLanguage"
:items="languageOptions"
:label="t('userSettings.preferredLanguage')"
:disabled="userProfileStore.isUpdating"
variant="outlined"
hide-details
/>
</div>
<div class="form-actions">

View File

@@ -9,6 +9,7 @@
mdiCalendar,
mdiChevronDown,
mdiCogOutline,
mdiEmailOutline,
mdiEyeOffOutline,
mdiFlagVariantOutline,
mdiFormatListBulleted,
@@ -79,6 +80,17 @@
contentViewActions.value.find(action => action.active) ?? contentViewActions.value[0]
);
async function forceReleaseDigestEmails() {
if (!window.confirm(t('releaseCommunications.developer.forceDigestConfirm'))) {
return;
}
const result = await releaseCommunicationsStore.forceDigestEmails();
window.alert(t('releaseCommunications.developer.forceDigestResult', {
count: result?.sentCount ?? 0,
}));
}
const appBarActions = computed(() => {
if (!authStore.isAuthenticated) {
return [];
@@ -111,7 +123,15 @@
}];
case 'developer-release-notes':
return route.query.tab === 'release-notes'
? []
? [
{
key: 'force-release-digest',
label: t('releaseCommunications.developer.forceDigest'),
icon: mdiEmailOutline,
loading: releaseCommunicationsStore.isForcingDigestEmails,
handler: forceReleaseDigestEmails,
},
]
: [
{
key: 'refresh-release-commits',
@@ -257,6 +277,25 @@
<v-icon :icon="action.icon" />
<span class="label">{{ action.label }}</span>
</button>
<button
v-else-if="action.handler"
class="menu-item-action"
type="button"
:disabled="action.loading"
@click="action.handler"
>
<v-progress-circular
v-if="action.loading"
indeterminate
size="18"
width="2"
/>
<v-icon
v-else
:icon="action.icon"
/>
<span class="label">{{ action.label }}</span>
</button>
<router-link
v-else
:to="action.route"

View File

@@ -33,10 +33,15 @@
isUserMenuOpen.value = !isUserMenuOpen.value;
}
function toggleLanguage() {
async function toggleLanguage() {
const nextLocale = locale.value === 'en' ? 'fr' : 'en';
languageStore.setLocale(nextLocale);
locale.value = nextLocale;
try {
await userProfileStore.changePreferredLanguage(nextLocale);
} catch (error) {
console.error('Failed to save preferred language:', error);
}
isUserMenuOpen.value = false;
}

View File

@@ -631,12 +631,13 @@
"creationDescription": "Draft a release note from selected commits or edit an existing release.",
"createReleaseNote": "Create Release Note",
"createFirstRelease": "Create First Release",
"pastReleases": "Past releases",
"pastReleasesDescription": "Published, archived, and draft release notes.",
"noReleaseNotes": "No release notes yet.",
"newUpdate": "New update",
"publish": "Publish",
"archive": "Archive",
"forceDigest": "Send daily email",
"forceDigestConfirm": "Send the daily release email now to every eligible user with unread updates?",
"forceDigestResult": "Daily release email sent to {count} users.",
"linkedCommits": "Linked commits",
"noLinkedCommits": "No commits linked to this update yet."
},
@@ -1110,6 +1111,7 @@
"lastname": "Last name",
"fullName": "Full name",
"email": "Email",
"preferredLanguage": "Preferred language",
"noEmail": "No email set",
"cropperTitle": "Update user portrait",
"savePortrait": "Save portrait",

View File

@@ -631,12 +631,13 @@
"creationDescription": "Rédigez une note depuis les commits sélectionnés ou modifiez une release existante.",
"createReleaseNote": "Créer une note de release",
"createFirstRelease": "Créer la première release",
"pastReleases": "Releases passées",
"pastReleasesDescription": "Notes de release publiées, archivées et brouillons.",
"noReleaseNotes": "Aucune note de release pour le moment.",
"newUpdate": "Nouvelle mise à jour",
"publish": "Publier",
"archive": "Archiver",
"forceDigest": "Envoyer l'email quotidien",
"forceDigestConfirm": "Envoyer maintenant l'email quotidien des releases a chaque utilisateur eligible avec des mises a jour non lues ?",
"forceDigestResult": "Email quotidien des releases envoye a {count} utilisateurs.",
"linkedCommits": "Commits liés",
"noLinkedCommits": "Aucun commit lié à cette mise à jour."
},
@@ -1110,6 +1111,7 @@
"lastname": "Nom",
"fullName": "Nom complet",
"email": "Email",
"preferredLanguage": "Langue préférée",
"noEmail": "Aucun email défini",
"cropperTitle": "Mettre à jour le portrait utilisateur",
"savePortrait": "Enregistrer le portrait",