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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddUserPreferredLanguage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<string>(
name: "PreferredLanguage",
table: "AspNetUsers",
type: "character varying(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "PreferredLanguage",
table: "AspNetUsers");
}
}
}

View File

@@ -1556,6 +1556,11 @@ namespace Socialize.Api.Migrations
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b.Property<string>("PreferredLanguage")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("RefreshToken") b.Property<string>("RefreshToken")
.HasMaxLength(44) .HasMaxLength(44)
.HasColumnType("character varying(44)"); .HasColumnType("character varying(44)");

View File

@@ -37,7 +37,8 @@ internal class IdentityService(
Firstname = user.Firstname, Firstname = user.Firstname,
Lastname = user.Lastname, Lastname = user.Lastname,
BirthDate = user.BirthDate, BirthDate = user.BirthDate,
Address = user.Address Address = user.Address,
PreferredLanguage = user.PreferredLanguage
}; };
ret = userModel; ret = userModel;

View File

@@ -13,6 +13,7 @@ internal class User : IdentityUser<Guid>
[MaxLength(2048)] public string? PortraitUrl { get; set; } [MaxLength(2048)] public string? PortraitUrl { get; set; }
[MaxLength(256)] public string? GoogleId { get; set; } [MaxLength(256)] public string? GoogleId { get; set; }
[MaxLength(256)] public string? FacebookId { get; set; } [MaxLength(256)] public string? FacebookId { get; set; }
[MaxLength(8)] public string PreferredLanguage { get; set; } = "en";
[MaxLength(44)] public string? RefreshToken { get; set; } [MaxLength(44)] public string? RefreshToken { get; set; }
public DateTime RefreshTokenExpiryTime { get; set; } public DateTime RefreshTokenExpiryTime { get; set; }
public DateTimeOffset? LastAuthenticatedAt { get; set; } public DateTimeOffset? LastAuthenticatedAt { get; set; }

View File

@@ -0,0 +1,57 @@
using FastEndpoints;
using Microsoft.AspNetCore.Identity;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Data;
namespace Socialize.Api.Modules.Identity.Handlers;
[PublicAPI]
internal record ChangePreferredLanguageRequest(string PreferredLanguage);
[PublicAPI]
internal class ChangePreferredLanguageValidator : Validator<ChangePreferredLanguageRequest>
{
public ChangePreferredLanguageValidator()
{
RuleFor(x => x.PreferredLanguage)
.Must(value => value is "en" or "fr")
.WithMessage("Preferred language must be en or fr.");
}
}
[PublicAPI]
internal class ChangePreferredLanguageHandler(UserManager userManager)
: Endpoint<ChangePreferredLanguageRequest>
{
public override void Configure()
{
Post("/api/users/preferred-language");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangePreferredLanguageRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.PreferredLanguage = request.PreferredLanguage;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -74,6 +74,7 @@ internal class GetCurrentUserQueryHandler(
Email = userModel.Email, Email = userModel.Email,
BirthDate = userModel.BirthDate, BirthDate = userModel.BirthDate,
Address = userModel.Address, Address = userModel.Address,
PreferredLanguage = userModel.PreferredLanguage,
UserRoles = roles UserRoles = roles
}, },
ct); ct);

View File

@@ -17,4 +17,5 @@ internal class UserDto
public string? PhoneNumber { get; init; } public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; } public DateTime? BirthDate { get; init; }
public string? Address { get; init; } public string? Address { get; init; }
public string PreferredLanguage { get; init; } = "en";
} }

View File

@@ -12,4 +12,5 @@ internal class UserModel
public string? PhoneNumber { get; init; } public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; } public DateTime? BirthDate { get; init; }
public string? Address { get; init; } public string? Address { get; init; }
public string PreferredLanguage { get; init; } = "en";
} }

View File

@@ -46,6 +46,8 @@ internal record ReleaseUpdateUnreadSummaryDto(
int ImportantUnreadCount, int ImportantUnreadCount,
IReadOnlyCollection<ReleaseUpdateDto> Updates); IReadOnlyCollection<ReleaseUpdateDto> Updates);
internal record ReleaseUpdateDigestSendResultDto(int SentCount);
internal static class ReleaseUpdateDtoMapper internal static class ReleaseUpdateDtoMapper
{ {
public static ReleaseUpdateDto ToDto(this ReleaseUpdate update, bool isRead) public static ReleaseUpdateDto ToDto(this ReleaseUpdate update, bool isRead)

View File

@@ -0,0 +1,28 @@
using FastEndpoints;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class ForceDeveloperReleaseUpdateDigestEmailsHandler(ReleaseUpdateEmailService emailService)
: EndpointWithoutRequest<ReleaseUpdateDigestSendResultDto>
{
public override void Configure()
{
Post("/api/developer/release-update-email-digests/force");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
int sentCount = await emailService.SendDueDigestEmailsAsync(
TimeSpan.Zero,
TimeSpan.Zero,
force: true,
ct: ct);
await SendOkAsync(new ReleaseUpdateDigestSendResultDto(sentCount), ct);
}
}

View File

@@ -40,7 +40,8 @@ internal sealed class ReleaseUpdateEmailDigestBackgroundService(
int sentCount = await emailService.SendDueDigestEmailsAsync( int sentCount = await emailService.SendDueDigestEmailsAsync(
TimeSpan.FromHours(options.Value.InactiveHoursBeforeDigest), TimeSpan.FromHours(options.Value.InactiveHoursBeforeDigest),
TimeSpan.FromHours(options.Value.DigestIntervalHours), TimeSpan.FromHours(options.Value.DigestIntervalHours),
stoppingToken); force: false,
ct: stoppingToken);
if (sentCount > 0 && logger.IsEnabled(LogLevel.Information)) if (sentCount > 0 && logger.IsEnabled(LogLevel.Information))
{ {
logger.LogInformation("Sent {SentCount} release update digest emails.", sentCount); logger.LogInformation("Sent {SentCount} release update digest emails.", sentCount);

View File

@@ -19,6 +19,7 @@ internal class ReleaseUpdateEmailService(
public async Task<int> SendDueDigestEmailsAsync( public async Task<int> SendDueDigestEmailsAsync(
TimeSpan inactiveThreshold, TimeSpan inactiveThreshold,
TimeSpan sendInterval, TimeSpan sendInterval,
bool force,
CancellationToken ct) CancellationToken ct)
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
@@ -30,7 +31,7 @@ internal class ReleaseUpdateEmailService(
foreach (User user in ownerUsers) foreach (User user in ownerUsers)
{ {
if (string.IsNullOrWhiteSpace(user.Email) || if (string.IsNullOrWhiteSpace(user.Email) ||
!ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore)) (!force && !ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore)))
{ {
continue; continue;
} }
@@ -40,7 +41,7 @@ internal class ReleaseUpdateEmailService(
.OrderByDescending(receipt => receipt.SentAt) .OrderByDescending(receipt => receipt.SentAt)
.Select(receipt => (DateTimeOffset?)receipt.SentAt) .Select(receipt => (DateTimeOffset?)receipt.SentAt)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
if (!ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore)) if (!force && !ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore))
{ {
continue; continue;
} }
@@ -61,8 +62,8 @@ internal class ReleaseUpdateEmailService(
await emailSender.SendEmailAsync( await emailSender.SendEmailAsync(
user.Email, user.Email,
"What's new in Socialize", GetDigestSubject(user.PreferredLanguage),
BuildDigestEmail(unreadUpdates)); BuildDigestEmail(unreadUpdates, user.PreferredLanguage));
dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt
{ {
@@ -86,25 +87,41 @@ internal class ReleaseUpdateEmailService(
.ToListAsync(ct); .ToListAsync(ct);
} }
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates) private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates, string? preferredLanguage)
{ {
bool useFrench = IsFrench(preferredLanguage);
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates"; string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
string listItems = string.Join( string listItems = string.Join(
Environment.NewLine, Environment.NewLine,
updates.Select(update => $""" updates.Select(update => $"""
<li> <li>
<strong>{HtmlEncode(update.Title)}</strong><br>{HtmlEncode(update.Summary)}<br> <strong>{HtmlEncode(useFrench ? update.TitleFr : update.Title)}</strong><br>
<strong>{HtmlEncode(update.TitleFr)}</strong><br>{HtmlEncode(update.SummaryFr)} {HtmlEncode(useFrench ? update.SummaryFr : update.Summary)}
</li> </li>
""")); """));
string heading = useFrench ? "Nouveautes dans Socialize" : "What's new in Socialize";
string linkText = useFrench ? "Ouvrir les nouveautes" : "Open What's New";
return $""" return $"""
<h1>What's new in Socialize</h1> <h1>{HtmlEncode(heading)}</h1>
<ul>{listItems}</ul> <ul>{listItems}</ul>
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p> <p><a href="{HtmlEncode(updateUrl)}">{HtmlEncode(linkText)}</a></p>
"""; """;
} }
private static string GetDigestSubject(string? preferredLanguage)
{
return IsFrench(preferredLanguage)
? "Nouveautes dans Socialize"
: "What's new in Socialize";
}
private static bool IsFrench(string? preferredLanguage)
{
return string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase);
}
private static string HtmlEncode(string? value) private static string HtmlEncode(string? value)
{ {
return WebUtility.HtmlEncode(value ?? string.Empty); return WebUtility.HtmlEncode(value ?? string.Empty);

View File

@@ -132,6 +132,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/api/developer/release-updates/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -580,6 +596,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/users/confirm-email-change": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1493,6 +1525,10 @@ export interface components {
titleFr: string; titleFr: string;
descriptionFr: string; descriptionFr: string;
}; };
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto: {
/** Format: int32 */
sentCount?: number;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: { SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
/** Format: int32 */ /** Format: int32 */
unreadCount?: number; unreadCount?: number;
@@ -1683,6 +1719,9 @@ export interface components {
/** Format: binary */ /** Format: binary */
file: string; file: string;
}; };
SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest: {
preferredLanguage?: string;
};
SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: { SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: {
message?: string; message?: string;
}; };
@@ -1708,6 +1747,7 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
birthDate?: string | null; birthDate?: string | null;
address?: string | null; address?: string | null;
preferredLanguage?: string;
}; };
SystemIOStream: components["schemas"]["SystemMarshalByRefObject"] & { SystemIOStream: components["schemas"]["SystemMarshalByRefObject"] & {
canTimeout?: boolean; 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: { SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler: {
parameters: { parameters: {
query?: never; 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: { SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler: {
parameters: { parameters: {
query: { query: {

View File

@@ -18,6 +18,7 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
const isLoading = ref(false); const isLoading = ref(false);
const isSaving = ref(false); const isSaving = ref(false);
const isRefreshingCommits = ref(false); const isRefreshingCommits = ref(false);
const isForcingDigestEmails = ref(false);
const error = ref(null); const error = ref(null);
const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0); 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) { async function linkCommit(sha, releaseUpdateId) {
await client.post(`/api/developer/release-commits/${sha}/link`, { releaseUpdateId }); await client.post(`/api/developer/release-commits/${sha}/link`, { releaseUpdateId });
await loadCommits(); await loadCommits();
@@ -202,6 +213,7 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
isLoading, isLoading,
isSaving, isSaving,
isRefreshingCommits, isRefreshingCommits,
isForcingDigestEmails,
error, error,
loadUserUpdates, loadUserUpdates,
loadUnreadSummary, loadUnreadSummary,
@@ -214,6 +226,7 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
archiveUpdate, archiveUpdate,
loadCommits, loadCommits,
refreshCommits, refreshCommits,
forceDigestEmails,
linkCommit, linkCommit,
linkCommitsToUpdate, linkCommitsToUpdate,
linkFirstReleaseCommits, linkFirstReleaseCommits,

View File

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

View File

@@ -3,12 +3,14 @@ import {defineStore} from 'pinia'
import {useAuthStore} from "@/features/auth/stores/authStore.js"; import {useAuthStore} from "@/features/auth/stores/authStore.js";
import {useClient} from "@/plugins/api.js"; import {useClient} from "@/plugins/api.js";
import {useSessionStorage} from "@vueuse/core"; import {useSessionStorage} from "@vueuse/core";
import {useLanguageStore} from "@/stores/languageStore.js";
export const useUserProfileStore = defineStore( export const useUserProfileStore = defineStore(
'user-profile', 'user-profile',
() => { () => {
const authStore = useAuthStore() const authStore = useAuthStore()
const languageStore = useLanguageStore()
const isUpdating = ref(false) const isUpdating = ref(false)
const isUploadingPortrait = ref(false) const isUploadingPortrait = ref(false)
const isLoadingCalendarFeed = ref(false) const isLoadingCalendarFeed = ref(false)
@@ -72,6 +74,7 @@ export const useUserProfileStore = defineStore(
const client = useClient() const client = useClient()
const userResponse = await client.get("/api/users/profile"); const userResponse = await client.get("/api/users/profile");
value.value = userResponse.data value.value = userResponse.data
languageStore.setLocale(userResponse.data?.preferredLanguage ?? 'en')
} catch (fetchError) { } catch (fetchError) {
console.error(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) { async function changeAddress(address) {
try { try {
const client = useClient() const client = useClient()
@@ -278,6 +303,7 @@ export const useUserProfileStore = defineStore(
changeBirthday, changeBirthday,
changePhone, changePhone,
changeEmail, changeEmail,
changePreferredLanguage,
changeAddress, changeAddress,
changePortrait, changePortrait,
fetchCalendarExportFeed, fetchCalendarExportFeed,

View File

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

View File

@@ -9,6 +9,7 @@
mdiCalendar, mdiCalendar,
mdiChevronDown, mdiChevronDown,
mdiCogOutline, mdiCogOutline,
mdiEmailOutline,
mdiEyeOffOutline, mdiEyeOffOutline,
mdiFlagVariantOutline, mdiFlagVariantOutline,
mdiFormatListBulleted, mdiFormatListBulleted,
@@ -79,6 +80,17 @@
contentViewActions.value.find(action => action.active) ?? contentViewActions.value[0] 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(() => { const appBarActions = computed(() => {
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
return []; return [];
@@ -111,7 +123,15 @@
}]; }];
case 'developer-release-notes': case 'developer-release-notes':
return route.query.tab === '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', key: 'refresh-release-commits',
@@ -257,6 +277,25 @@
<v-icon :icon="action.icon" /> <v-icon :icon="action.icon" />
<span class="label">{{ action.label }}</span> <span class="label">{{ action.label }}</span>
</button> </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 <router-link
v-else v-else
:to="action.route" :to="action.route"

View File

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

View File

@@ -631,12 +631,13 @@
"creationDescription": "Draft a release note from selected commits or edit an existing release.", "creationDescription": "Draft a release note from selected commits or edit an existing release.",
"createReleaseNote": "Create Release Note", "createReleaseNote": "Create Release Note",
"createFirstRelease": "Create First Release", "createFirstRelease": "Create First Release",
"pastReleases": "Past releases",
"pastReleasesDescription": "Published, archived, and draft release notes.",
"noReleaseNotes": "No release notes yet.", "noReleaseNotes": "No release notes yet.",
"newUpdate": "New update", "newUpdate": "New update",
"publish": "Publish", "publish": "Publish",
"archive": "Archive", "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", "linkedCommits": "Linked commits",
"noLinkedCommits": "No commits linked to this update yet." "noLinkedCommits": "No commits linked to this update yet."
}, },
@@ -1110,6 +1111,7 @@
"lastname": "Last name", "lastname": "Last name",
"fullName": "Full name", "fullName": "Full name",
"email": "Email", "email": "Email",
"preferredLanguage": "Preferred language",
"noEmail": "No email set", "noEmail": "No email set",
"cropperTitle": "Update user portrait", "cropperTitle": "Update user portrait",
"savePortrait": "Save 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.", "creationDescription": "Rédigez une note depuis les commits sélectionnés ou modifiez une release existante.",
"createReleaseNote": "Créer une note de release", "createReleaseNote": "Créer une note de release",
"createFirstRelease": "Créer la première 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.", "noReleaseNotes": "Aucune note de release pour le moment.",
"newUpdate": "Nouvelle mise à jour", "newUpdate": "Nouvelle mise à jour",
"publish": "Publier", "publish": "Publier",
"archive": "Archiver", "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", "linkedCommits": "Commits liés",
"noLinkedCommits": "Aucun commit lié à cette mise à jour." "noLinkedCommits": "Aucun commit lié à cette mise à jour."
}, },
@@ -1110,6 +1111,7 @@
"lastname": "Nom", "lastname": "Nom",
"fullName": "Nom complet", "fullName": "Nom complet",
"email": "Email", "email": "Email",
"preferredLanguage": "Langue préférée",
"noEmail": "Aucun email défini", "noEmail": "Aucun email défini",
"cropperTitle": "Mettre à jour le portrait utilisateur", "cropperTitle": "Mettre à jour le portrait utilisateur",
"savePortrait": "Enregistrer le portrait", "savePortrait": "Enregistrer le portrait",

View File

@@ -521,6 +521,40 @@
] ]
} }
}, },
"/api/developer/release-update-email-digests/force": {
"post": {
"tags": [
"Release Communications",
"Api"
],
"operationId": "SocializeApiModulesReleaseCommunicationsHandlersForceDeveloperReleaseUpdateDigestEmailsHandler",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
},
"security": [
{
"JWTBearerAuth": [
"developer"
]
}
]
}
},
"/api/developer/release-updates/{id}": { "/api/developer/release-updates/{id}": {
"get": { "get": {
"tags": [ "tags": [
@@ -1890,6 +1924,51 @@
] ]
} }
}, },
"/api/users/preferred-language": {
"post": {
"tags": [
"Users",
"Api"
],
"operationId": "SocializeApiModulesIdentityHandlersChangePreferredLanguageHandler",
"requestBody": {
"x-name": "ChangePreferredLanguageRequest",
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"JWTBearerAuth": []
}
]
}
},
"/api/users/confirm-email-change": { "/api/users/confirm-email-change": {
"get": { "get": {
"tags": [ "tags": [
@@ -5010,6 +5089,16 @@
} }
} }
}, },
"SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto": {
"type": "object",
"additionalProperties": false,
"properties": {
"sentCount": {
"type": "integer",
"format": "int32"
}
}
},
"SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto": { "SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -5618,6 +5707,15 @@
} }
} }
}, },
"SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest": {
"type": "object",
"additionalProperties": false,
"properties": {
"preferredLanguage": {
"type": "string"
}
}
},
"SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse": { "SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -5714,6 +5812,9 @@
"address": { "address": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"preferredLanguage": {
"type": "string"
} }
} }
}, },
@@ -7712,4 +7813,4 @@
} }
} }
} }
} }