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

This commit is contained in:
2026-05-07 21:00:59 -04:00
parent 7a8a0a44bf
commit b6eb348605
61 changed files with 8594 additions and 4 deletions

View File

@@ -0,0 +1,245 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js';
const DEFAULT_COMMIT_FILTERS = Object.freeze({
status: '',
updateId: '',
author: '',
search: '',
});
export const RELEASE_UPDATE_CATEGORIES = ['Feature', 'Improvement', 'Fix', 'Breaking Change'];
export const RELEASE_UPDATE_IMPORTANCE = ['Normal', 'Important'];
export const RELEASE_UPDATE_AUDIENCES = ['Everyone', 'OrganizationOwners', 'Developers'];
export const RELEASE_COMMIT_STATUSES = ['Unreviewed', 'Linked', 'InternalOnly', 'Ignored'];
export const useReleaseCommunicationsStore = defineStore('release-communications', () => {
const client = useClient();
const updates = ref([]);
const unreadSummary = ref({ unreadCount: 0, importantUnreadCount: 0, updates: [] });
const developerUpdates = ref([]);
const selectedUpdate = ref(null);
const commits = ref([]);
const commitFilters = ref({ ...DEFAULT_COMMIT_FILTERS });
const isLoading = ref(false);
const isSaving = ref(false);
const isSendingEmail = ref(false);
const isImporting = ref(false);
const error = ref(null);
const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0);
const importantUnreadCount = computed(() => unreadSummary.value?.importantUnreadCount ?? 0);
const unreviewedCommitCount = computed(() =>
commits.value.filter(commit => commit.communicationStatus === 'Unreviewed').length
);
const filteredCommits = computed(() => {
const query = commitFilters.value.search.trim().toLowerCase();
const author = commitFilters.value.author.trim().toLowerCase();
return commits.value.filter(commit => {
if (commitFilters.value.status && commit.communicationStatus !== commitFilters.value.status) {
return false;
}
if (commitFilters.value.updateId && commit.releaseUpdateId !== commitFilters.value.updateId) {
return false;
}
if (author) {
const authorText = `${commit.authorName ?? ''} ${commit.authorEmail ?? ''}`.toLowerCase();
if (!authorText.includes(author)) {
return false;
}
}
if (query) {
const haystack = [
commit.sha,
commit.shortSha,
commit.subject,
commit.authorName,
commit.authorEmail,
commit.deploymentLabel,
commit.sourceBranch,
].filter(Boolean).join(' ').toLowerCase();
if (!haystack.includes(query)) {
return false;
}
}
return true;
});
});
async function loadUserUpdates() {
isLoading.value = true;
error.value = null;
try {
const [updatesResponse, unreadResponse] = await Promise.all([
client.get('/api/release-updates'),
client.get('/api/release-updates/unread'),
]);
updates.value = updatesResponse.data ?? [];
unreadSummary.value = unreadResponse.data ?? { unreadCount: 0, importantUnreadCount: 0, updates: [] };
} catch (loadError) {
console.error('Failed to load release updates:', loadError);
error.value = 'releaseCommunications.errors.loadFailed';
throw loadError;
} finally {
isLoading.value = false;
}
}
async function markRead(id) {
await client.post(`/api/release-updates/${id}/read`);
updates.value = updates.value.map(update => update.id === id ? { ...update, isRead: true } : update);
await loadUnreadSummary();
}
async function markAllRead() {
await client.post('/api/release-updates/read-all');
updates.value = updates.value.map(update => ({ ...update, isRead: true }));
await loadUnreadSummary();
}
async function loadUnreadSummary() {
const response = await client.get('/api/release-updates/unread');
unreadSummary.value = response.data ?? { unreadCount: 0, importantUnreadCount: 0, updates: [] };
}
async function loadDeveloperUpdates() {
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/developer/release-updates');
developerUpdates.value = response.data ?? [];
} finally {
isLoading.value = false;
}
}
async function loadDeveloperUpdate(id) {
isLoading.value = true;
try {
const response = await client.get(`/api/developer/release-updates/${id}`);
selectedUpdate.value = response.data;
return selectedUpdate.value;
} finally {
isLoading.value = false;
}
}
async function saveDeveloperUpdate(payload, id = null) {
isSaving.value = true;
try {
const response = id
? await client.put(`/api/developer/release-updates/${id}`, payload)
: await client.post('/api/developer/release-updates', payload);
selectedUpdate.value = response.data;
await loadDeveloperUpdates();
return response.data;
} finally {
isSaving.value = false;
}
}
async function publishUpdate(id) {
const response = await client.post(`/api/developer/release-updates/${id}/publish`);
selectedUpdate.value = response.data;
await loadDeveloperUpdates();
return response.data;
}
async function archiveUpdate(id) {
const response = await client.post(`/api/developer/release-updates/${id}/archive`);
selectedUpdate.value = response.data;
await loadDeveloperUpdates();
return response.data;
}
async function sendUpdateEmail(id, payload) {
isSendingEmail.value = true;
try {
return (await client.post(`/api/developer/release-updates/${id}/send-email`, payload)).data;
} finally {
isSendingEmail.value = false;
}
}
async function loadCommits() {
const response = await client.get('/api/developer/release-commits');
commits.value = response.data ?? [];
}
async function importCommits(payload) {
isImporting.value = true;
try {
const response = await client.post('/api/developer/release-commits/import', payload);
await loadCommits();
return response.data;
} finally {
isImporting.value = false;
}
}
async function linkCommit(sha, releaseUpdateId) {
await client.post(`/api/developer/release-commits/${sha}/link`, { releaseUpdateId });
await loadCommits();
}
async function unlinkCommit(sha) {
await client.post(`/api/developer/release-commits/${sha}/unlink`);
await loadCommits();
}
async function markCommitInternalOnly(sha) {
await client.post(`/api/developer/release-commits/${sha}/internal-only`);
await loadCommits();
}
async function ignoreCommit(sha) {
await client.post(`/api/developer/release-commits/${sha}/ignore`);
await loadCommits();
}
function resetCommitFilters() {
commitFilters.value = { ...DEFAULT_COMMIT_FILTERS };
}
return {
updates,
unreadSummary,
developerUpdates,
selectedUpdate,
commits,
commitFilters,
filteredCommits,
unreadCount,
importantUnreadCount,
unreviewedCommitCount,
isLoading,
isSaving,
isSendingEmail,
isImporting,
error,
loadUserUpdates,
loadUnreadSummary,
markRead,
markAllRead,
loadDeveloperUpdates,
loadDeveloperUpdate,
saveDeveloperUpdate,
publishUpdate,
archiveUpdate,
sendUpdateEmail,
loadCommits,
importCommits,
linkCommit,
unlinkCommit,
markCommitInternalOnly,
ignoreCommit,
resetCommitFilters,
};
});

View File

@@ -0,0 +1,155 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
RELEASE_COMMIT_STATUSES,
useReleaseCommunicationsStore,
} from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n();
const store = useReleaseCommunicationsStore();
const importJson = ref('[]');
const updateOptions = computed(() =>
store.developerUpdates.map(update => ({ title: update.title, value: update.id }))
);
onMounted(async () => {
await Promise.all([store.loadDeveloperUpdates(), store.loadCommits()]);
});
async function importPayload() {
const commits = JSON.parse(importJson.value);
await store.importCommits({ commits });
}
function formatDate(value) {
return value ? new Date(value).toLocaleString() : '';
}
</script>
<template>
<section class="commits-page">
<header class="page-header">
<div>
<div class="eyebrow">{{ t('releaseCommunications.commits.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.commits.title') }}</h1>
<p>{{ t('releaseCommunications.commits.description') }}</p>
</div>
<strong>{{ store.unreviewedCommitCount }} {{ t('releaseCommunications.commits.unreviewed') }}</strong>
</header>
<section class="import-panel">
<v-textarea
v-model="importJson"
:label="t('releaseCommunications.commits.importJson')"
rows="5"
variant="outlined"
/>
<v-btn :loading="store.isImporting" @click="importPayload">{{ t('releaseCommunications.commits.import') }}</v-btn>
</section>
<section class="filter-panel">
<v-text-field v-model="store.commitFilters.search" :label="t('releaseCommunications.commits.search')" density="compact" variant="outlined" hide-details />
<v-select v-model="store.commitFilters.status" :items="RELEASE_COMMIT_STATUSES" :label="t('releaseCommunications.commits.status')" density="compact" variant="outlined" hide-details clearable />
<v-select v-model="store.commitFilters.updateId" :items="updateOptions" :label="t('releaseCommunications.commits.linkedUpdate')" density="compact" variant="outlined" hide-details clearable />
<v-text-field v-model="store.commitFilters.author" :label="t('releaseCommunications.commits.author')" density="compact" variant="outlined" hide-details clearable />
<v-btn variant="outlined" @click="store.resetCommitFilters">{{ t('releaseCommunications.commits.clear') }}</v-btn>
</section>
<section class="commit-table">
<article
v-for="commit in store.filteredCommits"
:key="commit.sha"
class="commit-row"
>
<div>
<code>{{ commit.shortSha }}</code>
<strong>{{ commit.subject }}</strong>
<small>{{ commit.authorName }} / {{ formatDate(commit.committedAt) }}</small>
</div>
<span>{{ commit.communicationStatus }}</span>
<v-select
:model-value="commit.releaseUpdateId"
:items="updateOptions"
:label="t('releaseCommunications.commits.link')"
density="compact"
variant="outlined"
hide-details
clearable
@update:model-value="value => value ? store.linkCommit(commit.sha, value) : store.unlinkCommit(commit.sha)"
/>
<div class="commit-actions">
<v-btn size="small" variant="outlined" @click="store.markCommitInternalOnly(commit.sha)">{{ t('releaseCommunications.commits.internalOnly') }}</v-btn>
<v-btn size="small" variant="outlined" @click="store.ignoreCommit(commit.sha)">{{ t('releaseCommunications.commits.ignore') }}</v-btn>
</div>
</article>
</section>
</section>
</template>
<style scoped>
.commits-page {
display: grid;
gap: 18px;
padding: 24px;
}
.page-header,
.filter-panel,
.commit-row,
.commit-actions {
display: flex;
gap: 12px;
}
.page-header {
align-items: flex-start;
justify-content: space-between;
}
.eyebrow {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.import-panel,
.filter-panel,
.commit-row {
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
padding: 14px;
}
.filter-panel {
align-items: center;
}
.commit-table {
display: grid;
gap: 10px;
}
.commit-row {
align-items: center;
display: grid;
grid-template-columns: minmax(0, 1fr) 120px minmax(220px, 320px) auto;
}
.commit-row > div:first-child {
display: grid;
gap: 3px;
}
@media (max-width: 900px) {
.filter-panel,
.commit-row {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,246 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
RELEASE_UPDATE_AUDIENCES,
RELEASE_UPDATE_CATEGORIES,
RELEASE_UPDATE_IMPORTANCE,
useReleaseCommunicationsStore,
} from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n();
const store = useReleaseCommunicationsStore();
const editingId = ref(null);
const form = reactive({
title: '',
summary: '',
body: '',
category: 'Feature',
importance: 'Normal',
audience: 'Everyone',
deploymentLabel: '',
buildVersion: '',
commitRange: '',
});
const emailTestMode = ref(true);
const confirmResend = ref(false);
const emailResult = ref(null);
const linkedCommits = computed(() =>
editingId.value
? store.commits.filter(commit => commit.releaseUpdateId === editingId.value)
: []
);
onMounted(async () => {
await Promise.all([store.loadDeveloperUpdates(), store.loadCommits()]);
});
function editUpdate(update) {
editingId.value = update.id;
Object.assign(form, {
title: update.title ?? '',
summary: update.summary ?? '',
body: update.body ?? '',
category: update.category ?? 'Feature',
importance: update.importance ?? 'Normal',
audience: update.audience ?? 'Everyone',
deploymentLabel: update.deploymentLabel ?? '',
buildVersion: update.buildVersion ?? '',
commitRange: update.commitRange ?? '',
});
store.selectedUpdate = update;
}
function newUpdate() {
editingId.value = null;
Object.assign(form, {
title: '',
summary: '',
body: '',
category: 'Feature',
importance: 'Normal',
audience: 'Everyone',
deploymentLabel: '',
buildVersion: '',
commitRange: '',
});
emailResult.value = null;
}
async function save() {
await store.saveDeveloperUpdate({ ...form }, editingId.value);
editingId.value = store.selectedUpdate?.id ?? editingId.value;
}
async function sendEmail() {
if (!editingId.value || !window.confirm(t('releaseCommunications.developer.confirmEmail'))) {
return;
}
emailResult.value = await store.sendUpdateEmail(editingId.value, {
testMode: emailTestMode.value,
confirmResend: confirmResend.value,
});
await store.loadDeveloperUpdate(editingId.value);
await store.loadDeveloperUpdates();
}
function formatDate(value) {
return value ? new Date(value).toLocaleString() : t('releaseCommunications.emptyValue');
}
</script>
<template>
<section class="developer-updates-page">
<header class="page-header">
<div>
<div class="eyebrow">{{ t('releaseCommunications.developer.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.developer.title') }}</h1>
</div>
<v-btn @click="newUpdate">{{ t('releaseCommunications.developer.newUpdate') }}</v-btn>
</header>
<section class="editor-grid">
<form
class="editor-panel"
@submit.prevent="save"
>
<v-text-field v-model="form.title" :label="t('title')" density="compact" variant="outlined" />
<v-textarea v-model="form.summary" :label="t('releaseCommunications.summary')" rows="2" variant="outlined" />
<v-textarea v-model="form.body" :label="t('releaseCommunications.body')" rows="5" variant="outlined" />
<div class="form-row">
<v-select v-model="form.category" :items="RELEASE_UPDATE_CATEGORIES" :label="t('releaseCommunications.category')" density="compact" variant="outlined" />
<v-select v-model="form.importance" :items="RELEASE_UPDATE_IMPORTANCE" :label="t('releaseCommunications.importance')" density="compact" variant="outlined" />
<v-select v-model="form.audience" :items="RELEASE_UPDATE_AUDIENCES" :label="t('releaseCommunications.audience')" density="compact" variant="outlined" />
</div>
<div class="form-row">
<v-text-field v-model="form.deploymentLabel" :label="t('releaseCommunications.deploymentLabel')" density="compact" variant="outlined" />
<v-text-field v-model="form.buildVersion" :label="t('releaseCommunications.buildVersion')" density="compact" variant="outlined" />
<v-text-field v-model="form.commitRange" :label="t('releaseCommunications.commitRange')" density="compact" variant="outlined" />
</div>
<div class="actions">
<v-btn type="submit" :loading="store.isSaving">{{ t('save') }}</v-btn>
<v-btn v-if="editingId" variant="outlined" @click="store.publishUpdate(editingId)">{{ t('releaseCommunications.developer.publish') }}</v-btn>
<v-btn v-if="editingId" variant="outlined" @click="store.archiveUpdate(editingId)">{{ t('releaseCommunications.developer.archive') }}</v-btn>
</div>
<div
v-if="editingId"
class="email-panel"
>
<strong>{{ t('releaseCommunications.developer.pushEmail') }}</strong>
<v-checkbox v-model="emailTestMode" :label="t('releaseCommunications.developer.testMode')" density="compact" hide-details />
<v-checkbox v-model="confirmResend" :label="t('releaseCommunications.developer.confirmResend')" density="compact" hide-details />
<v-btn variant="outlined" :loading="store.isSendingEmail" @click="sendEmail">{{ t('releaseCommunications.developer.sendEmail') }}</v-btn>
<small v-if="emailResult">{{ t('releaseCommunications.developer.emailResult', { count: emailResult.recipientCount }) }}</small>
</div>
</form>
<aside class="updates-panel">
<button
v-for="update in store.developerUpdates"
:key="update.id"
class="update-row"
type="button"
@click="editUpdate(update)"
>
<strong>{{ update.title }}</strong>
<span>{{ update.status }} / {{ update.audience }}</span>
<small>{{ formatDate(update.publishedAt ?? update.createdAt) }}</small>
</button>
</aside>
</section>
<section
v-if="editingId"
class="linked-commits"
>
<h2>{{ t('releaseCommunications.developer.linkedCommits') }}</h2>
<div v-if="!linkedCommits.length" class="page-message">{{ t('releaseCommunications.developer.noLinkedCommits') }}</div>
<div
v-for="commit in linkedCommits"
:key="commit.sha"
class="commit-chip"
>
<code>{{ commit.shortSha }}</code>
<span>{{ commit.subject }}</span>
</div>
</section>
</section>
</template>
<style scoped>
.developer-updates-page {
display: grid;
gap: 20px;
padding: 24px;
}
.page-header,
.actions,
.form-row {
display: flex;
gap: 12px;
}
.page-header {
align-items: center;
justify-content: space-between;
}
.eyebrow {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.editor-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.7fr);
gap: 16px;
}
.editor-panel,
.updates-panel,
.linked-commits {
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
padding: 16px;
}
.update-row {
display: grid;
width: 100%;
gap: 3px;
border: 0;
border-bottom: 1px solid #e2e8f0;
background: transparent;
padding: 10px 0;
text-align: left;
}
.email-panel {
display: grid;
gap: 8px;
margin-top: 16px;
border-top: 1px solid #e2e8f0;
padding-top: 16px;
}
.commit-chip {
display: flex;
gap: 10px;
padding: 8px 0;
}
@media (max-width: 900px) {
.editor-grid,
.form-row {
grid-template-columns: 1fr;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,167 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n();
const route = useRoute();
const store = useReleaseCommunicationsStore();
const highlightedId = computed(() => route.query.updateId);
onMounted(async () => {
await store.loadUserUpdates();
if (highlightedId.value) {
await store.markRead(highlightedId.value);
}
});
function formatDate(value) {
return value ? new Date(value).toLocaleString() : '';
}
</script>
<template>
<section class="updates-page">
<header class="updates-header">
<div>
<div class="eyebrow">{{ t('releaseCommunications.user.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.user.title') }}</h1>
<p>{{ t('releaseCommunications.user.description') }}</p>
</div>
<v-btn
variant="outlined"
:disabled="!store.unreadCount"
@click="store.markAllRead"
>
{{ t('releaseCommunications.user.markAllRead') }}
</v-btn>
</header>
<div
v-if="store.isLoading"
class="page-message"
>
{{ t('loading') }}
</div>
<section
v-else
class="updates-list"
>
<article
v-for="update in store.updates"
:key="update.id"
class="update-entry"
:class="{ 'update-entry-unread': !update.isRead, 'update-entry-highlight': update.id === highlightedId }"
@click="!update.isRead && store.markRead(update.id)"
>
<div class="update-meta">
<span>{{ update.category }}</span>
<span>{{ update.importance }}</span>
<time>{{ formatDate(update.publishedAt) }}</time>
</div>
<h2>{{ update.title }}</h2>
<p>{{ update.summary }}</p>
<div
v-if="update.body"
class="update-body"
>
{{ update.body }}
</div>
</article>
<div
v-if="!store.updates.length"
class="page-message"
>
{{ t('releaseCommunications.user.empty') }}
</div>
</section>
</section>
</template>
<style scoped>
.updates-page {
display: grid;
gap: 20px;
padding: 24px;
}
.updates-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.eyebrow,
.update-meta {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.updates-header h1 {
margin: 4px 0;
font-size: 1.75rem;
}
.updates-header p {
margin: 0;
color: #64748b;
}
.updates-list {
display: grid;
gap: 12px;
}
.update-entry {
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
padding: 16px;
}
.update-entry-unread {
border-color: rgb(var(--v-theme-primary));
box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary));
cursor: pointer;
}
.update-entry-highlight {
outline: 2px solid rgb(var(--v-theme-primary));
}
.update-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
color: #64748b;
}
.update-entry h2 {
margin: 0 0 6px;
font-size: 1.1rem;
}
.update-entry p {
margin: 0;
color: #334155;
}
.update-body {
margin-top: 12px;
color: #475569;
white-space: pre-line;
}
.page-message {
padding: 24px;
color: #64748b;
}
</style>