feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging

This commit is contained in:
Rotimi Best
2026-03-30 08:24:55 +01:00
parent ff2ad59377
commit 5a8f50f67b
131 changed files with 5643 additions and 2134 deletions
@@ -0,0 +1,42 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import type { OrganizationAudienceMember } from '$features/org/utils/types';
interface Props {
open: boolean;
member: OrganizationAudienceMember | null;
isDeleting?: boolean;
onDelete: () => void | Promise<void>;
}
let { open = $bindable(false), member, isDeleting = false, onDelete }: Props = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Content class="w-full max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('audience.delete.title')}</Dialog.Title>
<Dialog.Description>{$t('audience.delete.description')}</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<p class="text-sm">
{$t('audience.delete.confirmation', {
email: member?.email ?? '',
name: member?.name ?? member?.email ?? ''
})}
</p>
<div class="flex justify-end gap-2">
<Button variant="outline" onclick={() => (open = false)} disabled={isDeleting}>
{$t('audience.delete.cancel')}
</Button>
<Button variant="destructive" onclick={onDelete} loading={isDeleting} disabled={!member || isDeleting}>
{$t('audience.delete.confirm')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -20,16 +20,31 @@
selected: boolean;
onToggleSelect: () => void;
inviteActionEmail: string | null;
deletingMemberId: string | null;
canDeleteMembers: boolean;
onResendInvite: (email: string) => void;
onRevokeInvite: (email: string) => void;
onDelete: () => void;
}
let { row, memberDetailHref, selected, onToggleSelect, inviteActionEmail, onResendInvite, onRevokeInvite }: Props =
$props();
let {
row,
memberDetailHref,
selected,
onToggleSelect,
inviteActionEmail,
deletingMemberId,
canDeleteMembers,
onResendInvite,
onRevokeInvite,
onDelete
}: Props = $props();
const showInviteActions = $derived(
!row.profileId && (canResendAudienceInvite(row.status) || canRevokeAudienceInvite(row.status))
);
const isActionDisabled = $derived(inviteActionEmail === row.email || deletingMemberId === String(row.id));
const showRowActions = $derived(showInviteActions || canDeleteMembers);
</script>
<Table.Row>
@@ -55,31 +70,40 @@
</Table.Cell>
<Table.Cell>{row.createdAt}</Table.Cell>
<Table.Cell class="text-right">
{#if showInviteActions}
{#if showRowActions}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="hover:ui:bg-muted inline-flex items-center justify-center rounded-md p-1.5"
aria-label={$t('audience.invite.row_actions_aria')}
disabled={inviteActionEmail !== null}
disabled={isActionDisabled}
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon class="ui:size-4 ui:text-muted-foreground" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if canResendAudienceInvite(row.status)}
<DropdownMenu.Item disabled={inviteActionEmail === row.email} onclick={() => onResendInvite(row.email)}>
<DropdownMenu.Item disabled={isActionDisabled} onclick={() => onResendInvite(row.email)}>
{$t('audience.invite.resend')}
</DropdownMenu.Item>
{/if}
{#if canRevokeAudienceInvite(row.status)}
<DropdownMenu.Item
class="ui:text-destructive focus:ui:text-destructive"
disabled={inviteActionEmail === row.email}
disabled={isActionDisabled}
onclick={() => onRevokeInvite(row.email)}
>
{$t('audience.invite.revoke')}
</DropdownMenu.Item>
{/if}
{#if canDeleteMembers}
<DropdownMenu.Item
class="ui:text-destructive focus:ui:text-destructive"
disabled={isActionDisabled}
onclick={onDelete}
>
{$t('audience.delete.action')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import * as Select from '@cio/ui/base/select';
import { Search } from '@cio/ui/custom/search';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
@@ -8,10 +9,28 @@
hasSelection: boolean;
selectedCount: number;
searchValue?: string;
sortValue: string;
onSortChange: (value: string) => void;
onOpenAssign: () => void;
}
let { hasSelection, selectedCount, searchValue = $bindable(''), onOpenAssign }: Props = $props();
let {
hasSelection,
selectedCount,
searchValue = $bindable(''),
sortValue,
onSortChange,
onOpenAssign
}: Props = $props();
const sortOptions = [
{ label: t.get('audience.sort.newest'), value: 'createdAt:desc' },
{ label: t.get('audience.sort.oldest'), value: 'createdAt:asc' },
{ label: t.get('audience.sort.name_asc'), value: 'name:asc' },
{ label: t.get('audience.sort.name_desc'), value: 'name:desc' },
{ label: t.get('audience.sort.email_asc'), value: 'email:asc' },
{ label: t.get('audience.sort.email_desc'), value: 'email:desc' }
];
</script>
<Page.BodyHeader>
@@ -25,6 +44,22 @@
</Button>
</div>
{:else}
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="" />
<div class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between">
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="w-full md:max-w-sm" />
<Select.Root type="single" value={sortValue} onValueChange={onSortChange} name="audience-sort">
<Select.Trigger class="w-full md:w-[220px]">
{sortOptions.find((option) => option.value === sortValue)?.label ?? $t('audience.sort.label')}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each sortOptions as option (option.value)}
<Select.Item value={option.value} label={option.label} disabled={option.value === sortValue}>
{option.label}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
{/if}
</Page.BodyHeader>
@@ -20,8 +20,11 @@
isRowSelected: (rowId: string) => boolean;
onToggleRow: (rowId: string) => void;
inviteActionEmail: string | null;
deletingMemberId: string | null;
canDeleteMembers: boolean;
onResendInvite: (email: string) => void;
onRevokeInvite: (email: string) => void;
onDeleteRow: (row: OrganizationAudienceMember) => void;
}
let {
@@ -33,8 +36,11 @@
isRowSelected,
onToggleRow,
inviteActionEmail,
deletingMemberId,
canDeleteMembers,
onResendInvite,
onRevokeInvite
onRevokeInvite,
onDeleteRow
}: Props = $props();
function memberHref(row: OrganizationAudienceMember): string | null {
@@ -71,8 +77,11 @@
selected={isRowSelected(String(row.id))}
onToggleSelect={() => onToggleRow(String(row.id))}
{inviteActionEmail}
{deletingMemberId}
{canDeleteMembers}
{onResendInvite}
{onRevokeInvite}
onDelete={() => onDeleteRow(row)}
/>
{/each}
</Table.Body>
@@ -1,5 +1,6 @@
export { default as AssignCoursesModal } from './assign-courses-modal.svelte';
export { default as AudienceMemberRow } from './audience-member-row.svelte';
export { default as AudienceDeleteConfirmation } from './audience-delete-confirmation.svelte';
export { default as AudiencePagination } from './audience-pagination.svelte';
export { default as AudienceTable } from './audience-table.svelte';
export { default as AudienceTableSkeleton } from './audience-table-skeleton.svelte';
@@ -1,17 +1,25 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import UsersIcon from '@lucide/svelte/icons/users';
import { orgApi } from '$features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import { UpgradeBanner } from '$features/ui';
import { currentOrgMaxAudience } from '$lib/utils/store/org';
import type { OrganizationAudience } from '$features/org/utils/types';
import { currentOrgMaxAudience, isOrgAdmin } from '$lib/utils/store/org';
import type {
OrganizationAudience,
OrganizationAudienceMember,
OrganizationAudiencePagination,
OrganizationAudienceQuery
} from '$features/org/utils/types';
import AssignCoursesModal from '$features/audience/components/assign-courses-modal.svelte';
import AudienceDeleteConfirmation from '$features/audience/components/audience-delete-confirmation.svelte';
import AudienceTableToolbar from '$features/audience/components/audience-table-toolbar.svelte';
import AudienceTableSkeleton from '$features/audience/components/audience-table-skeleton.svelte';
import AudienceTable from '$features/audience/components/audience-table.svelte';
import AudiencePagination from '$features/audience/components/audience-pagination.svelte';
import { SvelteSet } from 'svelte/reactivity';
import { DEFAULT_ORG_AUDIENCE_QUERY, getAudienceSearchParams } from '$features/org/utils/audience-query-utils';
interface Course {
id: string;
@@ -20,15 +28,17 @@
interface Props {
audience?: OrganizationAudience;
pagination?: OrganizationAudiencePagination | null;
query: OrganizationAudienceQuery;
courses?: Course[];
}
let { audience, courses = [] }: Props = $props();
let { audience, pagination = null, query, courses = [] }: Props = $props();
$effect(() => {
if (audience) {
orgApi.audience = audience;
}
orgApi.audience = audience ?? [];
orgApi.audiencePagination = pagination;
selectedIds.clear();
});
const headers = $derived([
@@ -39,11 +49,19 @@
]);
let inviteActionEmail = $state<string | null>(null);
let deletingMemberId = $state<string | null>(null);
let deleteCandidate = $state<OrganizationAudienceMember | null>(null);
let deleteDialogOpen = $state(false);
let searchValue = $state(query.search ?? '');
let sortValue = $state(`${query.sortBy}:${query.sortOrder}`);
async function handleResendInvite(email: string) {
inviteActionEmail = email;
try {
await orgApi.resendAudienceInvite({ email });
const result = await orgApi.resendAudienceInvite({ email });
if (result) {
await refreshAudience();
}
} finally {
inviteActionEmail = null;
}
@@ -52,32 +70,22 @@
async function handleRevokeInvite(email: string) {
inviteActionEmail = email;
try {
await orgApi.revokeAudienceInvite({ email });
const result = await orgApi.revokeAudienceInvite({ email });
if (result) {
await refreshAudience();
}
} finally {
inviteActionEmail = null;
}
}
let pageSize = $state(20);
let currentPage = $state(1);
let searchValue = $state('');
let selectedIds = new SvelteSet<string>();
let assignModalOpen = $state(false);
const filteredRows = $derived(
searchValue
? orgApi.audience.filter((row) =>
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
)
: orgApi.audience
);
const totalPages = $derived(Math.ceil(filteredRows.length / pageSize));
const startIndex = $derived((currentPage - 1) * pageSize);
const endIndex = $derived(startIndex + pageSize);
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
const selectablePageRows = $derived(paginatedRows.filter((row) => row.profileId));
const currentPage = $derived(pagination?.page ?? DEFAULT_ORG_AUDIENCE_QUERY.page);
const pageSize = $derived(pagination?.limit ?? DEFAULT_ORG_AUDIENCE_QUERY.limit);
const totalCount = $derived(pagination?.total ?? 0);
const selectablePageRows = $derived(orgApi.audience.filter((row) => row.profileId));
const allPageSelected = $derived(
selectablePageRows.length > 0 && selectablePageRows.every((row) => selectedIds.has(String(row.id)))
@@ -117,13 +125,110 @@
}
$effect(() => {
if (filteredRows.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
searchValue = query.search ?? '';
sortValue = `${query.sortBy}:${query.sortOrder}`;
});
$effect(() => {
const normalizedSearch = searchValue.trim() || undefined;
const currentSearch = query.search ?? undefined;
if (normalizedSearch === currentSearch) {
return;
}
const timeoutId = setTimeout(() => {
void navigateAudience({
...query,
page: 1,
search: normalizedSearch
});
}, 300);
return () => clearTimeout(timeoutId);
});
async function refreshAudience(pageOverride = currentPage) {
await navigateAudience({
...query,
page: pageOverride
});
}
async function navigateAudience(nextQuery: OrganizationAudienceQuery) {
const searchParams = getAudienceSearchParams(nextQuery);
const nextSearch = searchParams.toString();
const currentSearch = page.url.searchParams.toString();
await goto(`${page.url.pathname}?${nextSearch}`, {
replaceState: nextSearch === currentSearch,
keepFocus: true,
noScroll: true,
invalidateAll: true
});
}
function handlePageChange(nextPage: number) {
if (nextPage === currentPage) {
return;
}
void navigateAudience({
...query,
page: nextPage
});
}
function handleSortChange(nextSortValue: string) {
if (nextSortValue === sortValue) {
return;
}
sortValue = nextSortValue;
const [sortBy, sortOrder] = nextSortValue.split(':') as [
OrganizationAudienceQuery['sortBy'],
OrganizationAudienceQuery['sortOrder']
];
void navigateAudience({
...query,
page: 1,
sortBy,
sortOrder
});
}
function openDeleteConfirmation(member: OrganizationAudienceMember) {
deleteCandidate = member;
deleteDialogOpen = true;
}
async function handleDeleteAudienceMember() {
if (!deleteCandidate) {
return;
}
deletingMemberId = String(deleteCandidate.id);
try {
const result = await orgApi.deleteAudienceMember(Number(deleteCandidate.id));
if (!result) {
return;
}
selectedIds.delete(String(deleteCandidate.id));
const nextPage = orgApi.audience.length === 1 && currentPage > 1 ? currentPage - 1 : currentPage;
await refreshAudience(nextPage);
deleteCandidate = null;
deleteDialogOpen = false;
} finally {
deletingMemberId = null;
}
}
</script>
{#if orgApi.audience.length >= $currentOrgMaxAudience}
{#if totalCount >= $currentOrgMaxAudience}
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
{/if}
@@ -131,34 +236,30 @@
{hasSelection}
selectedCount={selectedIds.size}
bind:searchValue
{sortValue}
onSortChange={handleSortChange}
onOpenAssign={() => (assignModalOpen = true)}
/>
{#if orgApi.audience.length || orgApi.isLoading}
{#if totalCount > 0}
<div class="w-full space-y-4">
{#if orgApi.isLoading}
<AudienceTableSkeleton rowCount={pageSize} />
{:else}
<AudienceTable
{headers}
rows={paginatedRows}
{allPageSelected}
{somePageSelected}
onToggleSelectAll={toggleSelectAll}
isRowSelected={(id) => selectedIds.has(id)}
onToggleRow={toggleRow}
{inviteActionEmail}
onResendInvite={handleResendInvite}
onRevokeInvite={handleRevokeInvite}
/>
{/if}
<AudiencePagination
count={filteredRows.length}
perPage={pageSize}
page={currentPage}
onPageChange={(p) => (currentPage = p)}
<AudienceTable
{headers}
rows={orgApi.audience}
{allPageSelected}
{somePageSelected}
onToggleSelectAll={toggleSelectAll}
isRowSelected={(id) => selectedIds.has(id)}
onToggleRow={toggleRow}
{inviteActionEmail}
{deletingMemberId}
canDeleteMembers={$isOrgAdmin === true}
onResendInvite={handleResendInvite}
onRevokeInvite={handleRevokeInvite}
onDeleteRow={openDeleteConfirmation}
/>
<AudiencePagination count={totalCount} perPage={pageSize} page={currentPage} onPageChange={handlePageChange} />
</div>
{:else}
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
@@ -171,3 +272,10 @@
onclose={() => (assignModalOpen = false)}
onassigned={handleAssigned}
/>
<AudienceDeleteConfirmation
bind:open={deleteDialogOpen}
member={deleteCandidate}
isDeleting={deletingMemberId !== null}
onDelete={handleDeleteAudienceMember}
/>