Add case follow-up queues and personal update notifications
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Badge from '../primitives/Badge.svelte';
|
||||
import Button from '../primitives/Button.svelte';
|
||||
import CaseReviewDialog from './CaseReviewDialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
actions = [],
|
||||
checkedAt,
|
||||
permitted,
|
||||
onsubmitted
|
||||
}: {
|
||||
item: ModerationCase;
|
||||
actions?: ModerationCaseAction[];
|
||||
checkedAt: string;
|
||||
permitted: boolean;
|
||||
onsubmitted: () => void;
|
||||
} = $props();
|
||||
let open = $state(false);
|
||||
let clearing = $state(false);
|
||||
const overdue = $derived(!!item.reviewAt && Date.parse(item.reviewAt) <= Date.parse(checkedAt));
|
||||
$effect(() => {
|
||||
void item.id;
|
||||
open = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<section aria-label="Follow-up review">
|
||||
<div class="heading">
|
||||
<h2>Follow-up review</h2>
|
||||
{#if overdue}<Badge variant="warning">Overdue review</Badge>{/if}
|
||||
</div>
|
||||
{#if item.reviewAt}<p>
|
||||
<time datetime={item.reviewAt}>{$timeFormat.dateTime(item.reviewAt)}</time>
|
||||
</p>
|
||||
{#if item.reviewSanctionId}<p class="hint">Review after linked mute expires.</p>{/if}
|
||||
{:else}<p class="hint">No review scheduled.</p>{/if}
|
||||
{#if item.reviewReason}<p class="reason">{item.reviewReason}</p>{/if}
|
||||
{#if permitted}<div class="actions">
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
clearing = false;
|
||||
open = true;
|
||||
}}>{item.reviewAt ? 'Reschedule review' : 'Schedule review'}</Button
|
||||
>
|
||||
{#if item.reviewAt}<Button
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
clearing = true;
|
||||
open = true;
|
||||
}}>Complete or clear review</Button
|
||||
>{/if}
|
||||
</div>{/if}
|
||||
</section>
|
||||
|
||||
{#if permitted && open}<CaseReviewDialog bind:open {item} {actions} {clearing} {onsubmitted} />{/if}
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
}
|
||||
.heading,
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.heading {
|
||||
justify-content: space-between;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: var(--leading-body);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.reason {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Dialog from '../primitives/Dialog.svelte';
|
||||
import Field from '../primitives/Field.svelte';
|
||||
import Input from '../primitives/Input.svelte';
|
||||
import Select from '../primitives/Select.svelte';
|
||||
import Textarea from '../primitives/Textarea.svelte';
|
||||
import Button from '../primitives/Button.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item,
|
||||
actions,
|
||||
clearing = false,
|
||||
onsubmitted
|
||||
}: {
|
||||
open?: boolean;
|
||||
item: ModerationCase;
|
||||
actions: ModerationCaseAction[];
|
||||
clearing?: boolean;
|
||||
onsubmitted: () => void;
|
||||
} = $props();
|
||||
const openedRevision = untrack(() => item.revision);
|
||||
const caseId = untrack(() => item.id);
|
||||
const mutes = $derived(
|
||||
actions.filter(
|
||||
(action) => action.type === 'restriction' && action.kind === 'mute' && action.expiresAt
|
||||
)
|
||||
);
|
||||
let source = $state(untrack(() => item.reviewSanctionId ?? 'custom'));
|
||||
function browserDate(iso?: string | null): string {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
if (!Number.isFinite(date.getTime())) return '';
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
let date = $state(untrack(() => browserDate(item.reviewAt)));
|
||||
let reason = $state(untrack(() => (clearing ? '' : (item.reviewReason ?? ''))));
|
||||
let busy = $state(false);
|
||||
let uncertain = $state(false);
|
||||
let error = $state('');
|
||||
let pending: Record<string, unknown> | null = null;
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
if (!pending) {
|
||||
const chosen = new Date(date);
|
||||
if (
|
||||
!reason.trim() ||
|
||||
(!clearing && source === 'custom' && (!date || !Number.isFinite(chosen.getTime())))
|
||||
) {
|
||||
error = 'Choose a review date and record a reason for this follow-up change.';
|
||||
return;
|
||||
}
|
||||
if (!clearing && source !== 'custom' && !mutes.some((mute) => mute.id === source)) {
|
||||
error = 'Choose an available linked mute or a custom review date.';
|
||||
return;
|
||||
}
|
||||
pending = {
|
||||
op: 'schedule-case-review',
|
||||
id: caseId,
|
||||
revision: openedRevision,
|
||||
reason: reason.trim(),
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
...(clearing
|
||||
? { reviewAt: null }
|
||||
: source === 'custom'
|
||||
? { reviewAt: chosen.toISOString() }
|
||||
: { restrictionId: source })
|
||||
};
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch('/api/moderation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pending)
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || !result?.ok) {
|
||||
uncertain = response.status >= 500 || response.ok;
|
||||
if (!uncertain) pending = null;
|
||||
throw new Error(result?.error || 'Could not update the follow-up review.');
|
||||
}
|
||||
open = false;
|
||||
onsubmitted();
|
||||
} catch (cause) {
|
||||
if (pending) uncertain = true;
|
||||
error =
|
||||
cause instanceof Error ? cause.message : 'Could not update the follow-up review.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog
|
||||
bind:open
|
||||
title={clearing ? 'Complete or clear review' : 'Schedule follow-up review'}
|
||||
{busy}
|
||||
description={clearing
|
||||
? 'Record the review result or why the follow-up is no longer needed. This clears its due date.'
|
||||
: 'Follow-up reviews remain scheduled when a case is closed.'}
|
||||
>
|
||||
<form onsubmit={submit}>
|
||||
<fieldset disabled={busy || uncertain}>
|
||||
{#if !clearing}
|
||||
<Field label="Review timing"
|
||||
><Select
|
||||
bind:value={source}
|
||||
options={[
|
||||
{ value: 'custom', label: 'Choose a date and time' },
|
||||
...mutes.map((mute) => ({
|
||||
value: mute.id,
|
||||
label: `After mute expires · ${$timeFormat.dateTime(mute.expiresAt!)} · ${mute.reason}`
|
||||
}))
|
||||
]}
|
||||
/></Field
|
||||
>
|
||||
{#if source === 'custom'}<Field label="Review date (browser local time)" required
|
||||
><Input type="datetime-local" bind:value={date} required /></Field
|
||||
>
|
||||
{:else}<p class="hint">
|
||||
The review will be due at the linked mute's recorded expiration time.
|
||||
</p>{/if}
|
||||
{/if}
|
||||
<Field
|
||||
label={clearing ? 'Review result or clearing reason' : 'Follow-up reason'}
|
||||
required><Textarea bind:value={reason} required rows={3} maxlength={4000} /></Field
|
||||
>
|
||||
</fieldset>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if uncertain}<p class="hint">
|
||||
The result is uncertain. Retry sends the same request safely.
|
||||
</p>{/if}
|
||||
<div class="actions">
|
||||
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={busy}
|
||||
>{uncertain
|
||||
? 'Retry same request'
|
||||
: clearing
|
||||
? 'Complete or clear review'
|
||||
: 'Save follow-up'}</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
form,
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
fieldset {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import type { ModerationCaseNotification } from '$lib/moderation.js';
|
||||
import Button from '../primitives/Button.svelte';
|
||||
import Badge from '../primitives/Badge.svelte';
|
||||
import RelativeTime from '../RelativeTime.svelte';
|
||||
|
||||
let {
|
||||
caseId,
|
||||
notifications = [],
|
||||
unread = 0,
|
||||
permitted,
|
||||
onsubmitted
|
||||
}: {
|
||||
caseId: string;
|
||||
notifications?: ModerationCaseNotification[];
|
||||
unread?: number;
|
||||
permitted: boolean;
|
||||
onsubmitted: () => void;
|
||||
} = $props();
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let pending: Record<string, unknown> | null = $state(null);
|
||||
const throughId = $derived(Math.max(0, ...notifications.map((item) => item.id)));
|
||||
$effect(() => {
|
||||
void caseId;
|
||||
pending = null;
|
||||
error = '';
|
||||
});
|
||||
async function markSeen() {
|
||||
if (busy || !permitted || (!pending && !throughId)) return;
|
||||
pending ??= {
|
||||
op: 'read-case-updates',
|
||||
id: caseId,
|
||||
throughId,
|
||||
idempotencyKey: crypto.randomUUID()
|
||||
};
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch('/api/moderation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pending)
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || !result?.ok) {
|
||||
if (response.status < 500 && !response.ok) pending = null;
|
||||
throw new Error(result?.error || 'Could not mark these updates as seen.');
|
||||
}
|
||||
pending = null;
|
||||
onsubmitted();
|
||||
} catch (cause) {
|
||||
error =
|
||||
cause instanceof Error ? cause.message : 'Could not mark these updates as seen.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section aria-label="Your case updates">
|
||||
<div class="heading">
|
||||
<h2>Your updates</h2>
|
||||
{#if unread}<Badge variant="warning">{unread} unread</Badge>{/if}
|
||||
</div>
|
||||
{#if notifications.length}<ol>
|
||||
{#each notifications as update (update.id)}<li>
|
||||
<div class="heading">
|
||||
<strong
|
||||
>{update.kind === 'evidence'
|
||||
? 'Evidence or note added'
|
||||
: 'Assignment changed'}</strong
|
||||
>{#if !update.readAt}<Badge>Unread</Badge>{/if}
|
||||
</div>
|
||||
<RelativeTime value={update.createdAt} />
|
||||
</li>{/each}
|
||||
</ol>{:else}<p class="hint">No personal case updates.</p>{/if}
|
||||
{#if notifications.length >= 25}<p class="hint">
|
||||
Showing your latest 25 updates for this case.
|
||||
</p>{/if}
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if permitted && (unread || pending)}<p class="hint">
|
||||
Marks your updates through the newest shown entry as read.
|
||||
</p>
|
||||
<Button size="sm" disabled={!throughId} loading={busy} onclick={markSeen}
|
||||
>{pending && error ? 'Retry same request' : 'Mark updates as seen'}</Button
|
||||
>{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
ol {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
</style>
|
||||
@@ -71,6 +71,8 @@ export const moderationActionLabels: Record<string, string> = {
|
||||
'case.reopened': 'Case reopened',
|
||||
'case.updated': 'Case updated',
|
||||
'case.assessed': 'Assessment revised',
|
||||
'case.review_scheduled': 'Follow-up updated',
|
||||
'schedule-case-review': 'Follow-up updated',
|
||||
'case.linked': 'Case relationship added',
|
||||
'case.unlinked': 'Case relationship removed',
|
||||
'link-case': 'Case relationship added',
|
||||
@@ -119,6 +121,8 @@ export function moderationEventDetail(detail: string, action: string): string {
|
||||
: moderationCaseRelationLabels[value.relation as ModerationCaseRelation];
|
||||
return `${label}: ${value.relatedCaseId}\n${value.reason}`;
|
||||
}
|
||||
if (action === 'case.review_scheduled' && typeof value.reason === 'string')
|
||||
return `${typeof value.reviewAt === 'string' ? `Review scheduled for ${value.reviewAt}` : 'Follow-up cleared'}\n${value.reason}`;
|
||||
if (action === 'case.assessed' && typeof value.summary === 'string')
|
||||
return `Assessment revision ${value.assessmentRevision}: ${value.summary}`;
|
||||
if (action === 'case.updated') {
|
||||
|
||||
Reference in New Issue
Block a user