Skip to content

Commit 710d96d

Browse files
authored
Merge pull request #221 from mchestr/enhancement/217-announcements-improvements
feat: Announcements system improvements (#217)
2 parents 41458c0 + 5c61e06 commit 710d96d

File tree

6 files changed

+770
-242
lines changed

6 files changed

+770
-242
lines changed

actions/announcements.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { authOptions } from "@/lib/auth"
44
import { prisma } from "@/lib/prisma"
5+
import { AuditEventType, logAuditEvent } from "@/lib/security/audit-log"
56
import { createLogger } from "@/lib/utils/logger"
67
import { getServerSession } from "next-auth"
78
import { revalidatePath } from "next/cache"
@@ -156,6 +157,13 @@ export async function createAnnouncement(input: CreateAnnouncementInput): Promis
156157
})
157158

158159
logger.info("Announcement created", { id: announcement.id, title, createdBy: session.user.id })
160+
logAuditEvent(AuditEventType.ANNOUNCEMENT_CREATED, session.user.id, {
161+
announcementId: announcement.id,
162+
title,
163+
priority,
164+
isActive,
165+
expiresAt: expiresAt ?? null,
166+
})
159167
revalidatePath("/")
160168
revalidatePath("/admin/announcements")
161169

@@ -220,6 +228,14 @@ export async function updateAnnouncement(input: UpdateAnnouncementInput): Promis
220228
})
221229

222230
logger.info("Announcement updated", { id, updatedBy: session.user.id })
231+
logAuditEvent(AuditEventType.ANNOUNCEMENT_UPDATED, session.user.id, {
232+
announcementId: id,
233+
title,
234+
content,
235+
priority,
236+
isActive,
237+
expiresAt: expiresAt ?? null,
238+
})
223239
revalidatePath("/")
224240
revalidatePath("/admin/announcements")
225241

@@ -249,6 +265,9 @@ export async function deleteAnnouncement(id: string): Promise<{ success: boolean
249265
})
250266

251267
logger.info("Announcement deleted", { id, deletedBy: session.user.id })
268+
logAuditEvent(AuditEventType.ANNOUNCEMENT_DELETED, session.user.id, {
269+
announcementId: id,
270+
})
252271
revalidatePath("/")
253272
revalidatePath("/admin/announcements")
254273

@@ -280,6 +299,10 @@ export async function setAnnouncementActive(id: string, isActive: boolean): Prom
280299
})
281300

282301
logger.info("Announcement status updated", { id, isActive, updatedBy: session.user.id })
302+
logAuditEvent(AuditEventType.ANNOUNCEMENT_STATUS_CHANGED, session.user.id, {
303+
announcementId: id,
304+
isActive,
305+
})
283306
revalidatePath("/")
284307
revalidatePath("/admin/announcements")
285308

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"use client"
2+
3+
import { StyledInput } from "@/components/ui/styled-input"
4+
5+
export interface AnnouncementFormData {
6+
title: string
7+
content: string
8+
priority: number
9+
isActive: boolean
10+
expiresAt: string
11+
}
12+
13+
interface AnnouncementFormModalProps {
14+
isOpen: boolean
15+
isEditing: boolean
16+
formData: AnnouncementFormData
17+
submitting: boolean
18+
onFormChange: (data: AnnouncementFormData) => void
19+
onSubmit: (e: React.FormEvent) => void
20+
onClose: () => void
21+
}
22+
23+
export function AnnouncementFormModal({
24+
isOpen,
25+
isEditing,
26+
formData,
27+
submitting,
28+
onFormChange,
29+
onSubmit,
30+
onClose,
31+
}: AnnouncementFormModalProps) {
32+
if (!isOpen) return null
33+
34+
return (
35+
<div
36+
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
37+
role="presentation"
38+
onClick={onClose}
39+
>
40+
<div
41+
className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-lg shadow-2xl max-h-[90vh] overflow-y-auto"
42+
role="dialog"
43+
aria-modal="true"
44+
aria-labelledby="modal-title"
45+
onClick={(e) => e.stopPropagation()}
46+
>
47+
{/* Modal Header */}
48+
<div className="flex items-center justify-between p-4 border-b border-slate-700">
49+
<h2 id="modal-title" className="text-lg font-semibold text-white">
50+
{isEditing ? "Edit Announcement" : "New Announcement"}
51+
</h2>
52+
<button
53+
onClick={onClose}
54+
className="p-1 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-colors"
55+
data-testid="announcement-modal-close"
56+
>
57+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
58+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
59+
</svg>
60+
</button>
61+
</div>
62+
63+
{/* Modal Body */}
64+
<form onSubmit={onSubmit} className="p-4 space-y-4">
65+
{/* Title */}
66+
<div>
67+
<label htmlFor="title" className="block text-sm font-medium text-slate-300 mb-1">
68+
Title
69+
</label>
70+
<StyledInput
71+
id="title"
72+
name="title"
73+
value={formData.title}
74+
onChange={(e) => onFormChange({ ...formData, title: e.target.value })}
75+
placeholder="Announcement title"
76+
required
77+
data-testid="announcement-title-input"
78+
/>
79+
</div>
80+
81+
{/* Content */}
82+
<div>
83+
<label htmlFor="content" className="block text-sm font-medium text-slate-300 mb-1">
84+
Content (Markdown supported)
85+
</label>
86+
<textarea
87+
id="content"
88+
name="content"
89+
value={formData.content}
90+
onChange={(e) => onFormChange({ ...formData, content: e.target.value })}
91+
placeholder="Announcement content... You can use **bold**, *italic*, and [links](url)"
92+
required
93+
rows={5}
94+
className="w-full bg-slate-800/50 border border-slate-600 hover:border-slate-500 rounded-lg px-4 py-2 text-sm text-white placeholder-slate-400 focus:outline-none focus:border-cyan-400 focus:ring-cyan-400 focus:ring-1 transition-colors resize-none"
95+
data-testid="announcement-content-input"
96+
/>
97+
</div>
98+
99+
{/* Priority and Active Row */}
100+
<div className="grid grid-cols-2 gap-4">
101+
<div>
102+
<label htmlFor="priority" className="block text-sm font-medium text-slate-300 mb-1">
103+
Priority
104+
</label>
105+
<StyledInput
106+
id="priority"
107+
name="priority"
108+
type="number"
109+
min={0}
110+
max={100}
111+
value={formData.priority}
112+
onChange={(e) => onFormChange({ ...formData, priority: parseInt(e.target.value) || 0 })}
113+
data-testid="announcement-priority-input"
114+
/>
115+
<p className="text-xs text-slate-500 mt-1">Higher = shown first</p>
116+
</div>
117+
<div>
118+
<label className="block text-sm font-medium text-slate-300 mb-1">Status</label>
119+
<label className="flex items-center gap-2 cursor-pointer mt-2">
120+
<input
121+
type="checkbox"
122+
checked={formData.isActive}
123+
onChange={(e) => onFormChange({ ...formData, isActive: e.target.checked })}
124+
className="w-4 h-4 rounded border-slate-600 bg-slate-800 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
125+
data-testid="announcement-active-checkbox"
126+
/>
127+
<span className="text-sm text-slate-300">Active</span>
128+
</label>
129+
</div>
130+
</div>
131+
132+
{/* Expiration Date */}
133+
<div>
134+
<label htmlFor="expiresAt" className="block text-sm font-medium text-slate-300 mb-1">
135+
Expiration Date (Optional)
136+
</label>
137+
<StyledInput
138+
id="expiresAt"
139+
name="expiresAt"
140+
type="datetime-local"
141+
value={formData.expiresAt}
142+
onChange={(e) => onFormChange({ ...formData, expiresAt: e.target.value })}
143+
data-testid="announcement-expires-input"
144+
/>
145+
<p className="text-xs text-slate-500 mt-1">Leave empty for no expiration</p>
146+
</div>
147+
148+
{/* Modal Footer */}
149+
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-700">
150+
<button
151+
type="button"
152+
onClick={onClose}
153+
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-600 rounded-lg transition-colors"
154+
>
155+
Cancel
156+
</button>
157+
<button
158+
type="submit"
159+
disabled={submitting}
160+
className="px-4 py-2 text-sm font-medium text-white bg-cyan-600 hover:bg-cyan-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
161+
data-testid="announcement-submit-button"
162+
>
163+
{submitting && (
164+
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
165+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
166+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
167+
</svg>
168+
)}
169+
{isEditing ? "Save Changes" : "Create"}
170+
</button>
171+
</div>
172+
</form>
173+
</div>
174+
</div>
175+
)
176+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"use client"
2+
3+
import type { AnnouncementData } from "@/actions/announcements"
4+
5+
interface AnnouncementListItemProps {
6+
announcement: AnnouncementData
7+
onToggleActive: (id: string, isActive: boolean) => void
8+
onEdit: (announcement: AnnouncementData) => void
9+
onDelete: (id: string) => void
10+
}
11+
12+
function isExpired(expiresAt: string | null): boolean {
13+
if (!expiresAt) return false
14+
return new Date(expiresAt) < new Date()
15+
}
16+
17+
export function AnnouncementListItem({
18+
announcement,
19+
onToggleActive,
20+
onEdit,
21+
onDelete,
22+
}: AnnouncementListItemProps) {
23+
return (
24+
<div
25+
className="bg-slate-800/50 border border-slate-700 rounded-lg p-4 sm:p-6"
26+
data-testid={`announcement-${announcement.id}`}
27+
>
28+
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
29+
<div className="flex-1 min-w-0">
30+
<div className="flex items-center gap-2 flex-wrap">
31+
<h3 className="text-lg font-semibold text-white">{announcement.title}</h3>
32+
{/* Status badge */}
33+
{!announcement.isActive ? (
34+
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-slate-600 text-slate-300">
35+
Inactive
36+
</span>
37+
) : isExpired(announcement.expiresAt) ? (
38+
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/20 text-red-400">
39+
Expired
40+
</span>
41+
) : (
42+
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400">
43+
Active
44+
</span>
45+
)}
46+
{/* Priority badge */}
47+
{announcement.priority > 0 && (
48+
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-cyan-500/20 text-cyan-400">
49+
Priority: {announcement.priority}
50+
</span>
51+
)}
52+
</div>
53+
<p className="mt-2 text-slate-300 text-sm whitespace-pre-wrap line-clamp-3">
54+
{announcement.content}
55+
</p>
56+
<div className="mt-3 flex items-center gap-4 text-xs text-slate-500">
57+
<span>Created: {new Date(announcement.createdAt).toLocaleDateString()}</span>
58+
{announcement.expiresAt && (
59+
<span>Expires: {new Date(announcement.expiresAt).toLocaleDateString()}</span>
60+
)}
61+
</div>
62+
</div>
63+
<div className="flex items-center gap-2 shrink-0">
64+
<button
65+
onClick={() => onToggleActive(announcement.id, !announcement.isActive)}
66+
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded-lg transition-colors"
67+
title={announcement.isActive ? "Deactivate" : "Activate"}
68+
aria-label={announcement.isActive ? "Deactivate announcement" : "Activate announcement"}
69+
data-testid={`toggle-announcement-${announcement.id}`}
70+
>
71+
{announcement.isActive ? (
72+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
73+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
74+
</svg>
75+
) : (
76+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
77+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
78+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
79+
</svg>
80+
)}
81+
</button>
82+
<button
83+
onClick={() => onEdit(announcement)}
84+
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded-lg transition-colors"
85+
title="Edit"
86+
aria-label="Edit announcement"
87+
data-testid={`edit-announcement-${announcement.id}`}
88+
>
89+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
90+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
91+
</svg>
92+
</button>
93+
<button
94+
onClick={() => onDelete(announcement.id)}
95+
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/20 rounded-lg transition-colors"
96+
title="Delete"
97+
aria-label="Delete announcement"
98+
data-testid={`delete-announcement-${announcement.id}`}
99+
>
100+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
101+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
102+
</svg>
103+
</button>
104+
</div>
105+
</div>
106+
</div>
107+
)
108+
}

0 commit comments

Comments
 (0)