create-nextblock 0.14.6 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ // app/cms/settings/site-scripts/actions.ts
2
+ 'use server';
3
+
4
+ import { createClient } from '@nextblock-cms/db/server';
5
+ import { revalidatePath, revalidateTag } from 'next/cache';
6
+
7
+ import type { SettingsActionResult } from '../../../../lib/cms/action-result';
8
+ import {
9
+ isSiteScriptLoadStrategy,
10
+ isSiteScriptPlacement,
11
+ SITE_SCRIPT_COLUMNS,
12
+ type SiteScript,
13
+ } from '../../../../lib/site-scripts/types';
14
+ import {
15
+ buildSiteScriptSnapshot,
16
+ SITE_SCRIPT_REVISION_COLUMNS,
17
+ type SiteScriptRevision,
18
+ type SiteScriptRevisionType,
19
+ } from '../../../../lib/site-scripts/revisions';
20
+
21
+ type SupabaseLike = ReturnType<typeof createClient>;
22
+
23
+ /**
24
+ * Append one row to the audit log.
25
+ *
26
+ * Never throws and never blocks the change it describes: by the time this runs the
27
+ * script has already been written, so failing here would report an error for an edit
28
+ * that actually happened. A missing log line is surfaced in the server log instead.
29
+ */
30
+ async function recordRevision(
31
+ supabase: SupabaseLike,
32
+ input: {
33
+ actorUserId: string | null;
34
+ revisionType: SiteScriptRevisionType;
35
+ scriptId: string | null;
36
+ snapshot: Parameters<typeof buildSiteScriptSnapshot>[0];
37
+ summary?: string;
38
+ }
39
+ ): Promise<void> {
40
+ const snapshot = buildSiteScriptSnapshot(input.snapshot);
41
+
42
+ try {
43
+ const { error } = await supabase.from('site_script_revisions').insert({
44
+ actor_user_id: input.actorUserId,
45
+ revision_type: input.revisionType,
46
+ script_id: input.scriptId,
47
+ script_name: snapshot.name,
48
+ snapshot,
49
+ source: 'cms',
50
+ summary: input.summary ?? null,
51
+ });
52
+
53
+ if (error) {
54
+ console.error('Site scripts: revision not recorded —', error.message);
55
+ }
56
+ } catch (error) {
57
+ console.error('Site scripts: revision not recorded —', error);
58
+ }
59
+ }
60
+
61
+ /** Read the current row so an update or delete can be logged with its prior state. */
62
+ async function readScript(supabase: SupabaseLike, id: string) {
63
+ const { data } = await supabase.from('site_scripts').select(SITE_SCRIPT_COLUMNS).eq('id', id).maybeSingle();
64
+
65
+ return data as SiteScript | null;
66
+ }
67
+
68
+ /**
69
+ * Site scripts run arbitrary JavaScript on every public page, so unlike most content
70
+ * these are ADMIN-only — a WRITER who can publish a page must not also be able to
71
+ * ship code to every visitor. RLS enforces the same rule; this is the friendly error.
72
+ */
73
+ async function requireAdmin() {
74
+ const supabase = createClient();
75
+ const {
76
+ data: { user },
77
+ } = await supabase.auth.getUser();
78
+
79
+ if (!user) {
80
+ return { supabase, userId: null, error: 'You must be logged in to manage site scripts.' as const };
81
+ }
82
+
83
+ const { data: profile, error: profileError } = await supabase
84
+ .from('profiles')
85
+ .select('role')
86
+ .eq('id', user.id)
87
+ .single();
88
+
89
+ if (profileError || !profile || profile.role !== 'ADMIN') {
90
+ return { supabase, userId: null, error: 'Only administrators can manage site scripts.' as const };
91
+ }
92
+
93
+ return { supabase, userId: user.id, error: null };
94
+ }
95
+
96
+ function revalidateSiteScripts() {
97
+ revalidateTag('public-layout-site-scripts', 'max');
98
+ revalidatePath('/', 'layout');
99
+ revalidatePath('/cms/settings/site-scripts');
100
+ }
101
+
102
+ export async function getSiteScripts(): Promise<SiteScript[]> {
103
+ const supabase = createClient();
104
+ const { data, error } = await supabase
105
+ .from('site_scripts')
106
+ .select(SITE_SCRIPT_COLUMNS)
107
+ .order('sort_order');
108
+
109
+ if (error || !data) return [];
110
+
111
+ return data as SiteScript[];
112
+ }
113
+
114
+ export interface SiteScriptInput {
115
+ name: string;
116
+ description?: string | null;
117
+ code?: string;
118
+ src?: string | null;
119
+ placement?: string;
120
+ load_strategy?: string;
121
+ is_active?: boolean;
122
+ sort_order?: number;
123
+ }
124
+
125
+ /** Normalise and validate, mirroring the table's CHECK constraints. */
126
+ function buildPayload(input: SiteScriptInput): Record<string, unknown> | { error: string } {
127
+ const name = (input.name || '').trim();
128
+
129
+ if (!name) {
130
+ return { error: 'A name is required.' };
131
+ }
132
+
133
+ const src = (input.src || '').trim();
134
+
135
+ if (src && !/^https:\/\//i.test(src)) {
136
+ return { error: 'An external script URL must start with https://.' };
137
+ }
138
+
139
+ const code = input.code ?? '';
140
+
141
+ if (!src && !code.trim()) {
142
+ return { error: 'Add some JavaScript, or an external script URL.' };
143
+ }
144
+
145
+ const placement = isSiteScriptPlacement(input.placement) ? input.placement : 'body_end';
146
+ const loadStrategy = isSiteScriptLoadStrategy(input.load_strategy) ? input.load_strategy : 'default';
147
+
148
+ return {
149
+ code,
150
+ description: (input.description || '').trim() || null,
151
+ is_active: Boolean(input.is_active),
152
+ load_strategy: loadStrategy,
153
+ name,
154
+ placement,
155
+ sort_order: Number.isFinite(input.sort_order) ? Number(input.sort_order) : 0,
156
+ src: src || null,
157
+ };
158
+ }
159
+
160
+ export async function createSiteScript(input: SiteScriptInput): Promise<SettingsActionResult> {
161
+ const { supabase, userId, error: authError } = await requireAdmin();
162
+ if (authError) return { ok: false, error: authError };
163
+
164
+ const payload = buildPayload(input);
165
+ if ('error' in payload) return { ok: false, error: payload.error as string };
166
+
167
+ const { data, error } = await supabase
168
+ .from('site_scripts')
169
+ .insert(payload as never)
170
+ .select('id')
171
+ .single();
172
+
173
+ if (error) {
174
+ return { ok: false, error: `Failed to create the script: ${error.message}` };
175
+ }
176
+
177
+ await recordRevision(supabase, {
178
+ actorUserId: userId,
179
+ revisionType: 'create',
180
+ scriptId: (data as { id: string } | null)?.id ?? null,
181
+ snapshot: payload,
182
+ });
183
+
184
+ revalidateSiteScripts();
185
+ return { ok: true, message: 'Script created.' };
186
+ }
187
+
188
+ export async function updateSiteScript(
189
+ id: string,
190
+ input: SiteScriptInput
191
+ ): Promise<SettingsActionResult> {
192
+ const { supabase, userId, error: authError } = await requireAdmin();
193
+ if (authError) return { ok: false, error: authError };
194
+
195
+ const payload = buildPayload(input);
196
+ if ('error' in payload) return { ok: false, error: payload.error as string };
197
+
198
+ // Snapshot the PRIOR state: reverting means going back to what it was before
199
+ // this edit, so that is the state worth keeping.
200
+ const previous = await readScript(supabase, id);
201
+
202
+ const { error } = await supabase.from('site_scripts').update(payload as never).eq('id', id);
203
+
204
+ if (error) {
205
+ return { ok: false, error: `Failed to update the script: ${error.message}` };
206
+ }
207
+
208
+ if (previous) {
209
+ await recordRevision(supabase, {
210
+ actorUserId: userId,
211
+ revisionType: 'update',
212
+ scriptId: id,
213
+ snapshot: previous,
214
+ summary: `Edited “${previous.name}”`,
215
+ });
216
+ }
217
+
218
+ revalidateSiteScripts();
219
+ return { ok: true, message: 'Script updated.' };
220
+ }
221
+
222
+ export async function setSiteScriptActive(
223
+ id: string,
224
+ isActive: boolean
225
+ ): Promise<SettingsActionResult> {
226
+ const { supabase, userId, error: authError } = await requireAdmin();
227
+ if (authError) return { ok: false, error: authError };
228
+
229
+ const previous = await readScript(supabase, id);
230
+
231
+ const { error } = await supabase.from('site_scripts').update({ is_active: isActive }).eq('id', id);
232
+
233
+ if (error) {
234
+ return { ok: false, error: `Failed to change the script state: ${error.message}` };
235
+ }
236
+
237
+ if (previous) {
238
+ await recordRevision(supabase, {
239
+ actorUserId: userId,
240
+ revisionType: 'update',
241
+ scriptId: id,
242
+ snapshot: previous,
243
+ summary: `${isActive ? 'Enabled' : 'Disabled'} “${previous.name}”`,
244
+ });
245
+ }
246
+
247
+ revalidateSiteScripts();
248
+ return { ok: true, message: isActive ? 'Script enabled.' : 'Script disabled.' };
249
+ }
250
+
251
+ export async function deleteSiteScript(id: string): Promise<SettingsActionResult> {
252
+ const { supabase, userId, error: authError } = await requireAdmin();
253
+ if (authError) return { ok: false, error: authError };
254
+
255
+ // Read before deleting: the snapshot is what makes the delete undoable.
256
+ const previous = await readScript(supabase, id);
257
+
258
+ const { error } = await supabase.from('site_scripts').delete().eq('id', id);
259
+
260
+ if (error) {
261
+ return { ok: false, error: `Failed to delete the script: ${error.message}` };
262
+ }
263
+
264
+ if (previous) {
265
+ await recordRevision(supabase, {
266
+ actorUserId: userId,
267
+ revisionType: 'delete',
268
+ scriptId: id,
269
+ snapshot: previous,
270
+ summary: `Deleted “${previous.name}”`,
271
+ });
272
+ }
273
+
274
+ revalidateSiteScripts();
275
+ return { ok: true, message: 'Script deleted. You can restore it from the history.' };
276
+ }
277
+
278
+ export async function getSiteScriptRevisions(limit = 100): Promise<SiteScriptRevision[]> {
279
+ const supabase = createClient();
280
+ const { data, error } = await supabase
281
+ .from('site_script_revisions')
282
+ .select(SITE_SCRIPT_REVISION_COLUMNS)
283
+ .order('created_at', { ascending: false })
284
+ .limit(limit);
285
+
286
+ if (error || !data) return [];
287
+
288
+ return data as unknown as SiteScriptRevision[];
289
+ }
290
+
291
+ /**
292
+ * Restore a script to a logged revision.
293
+ *
294
+ * Works for a deleted script too: the row is recreated from the snapshot. The
295
+ * restore itself is logged as a 'revert' revision rather than removing history, so
296
+ * the audit trail stays append-only and the restore is itself undoable.
297
+ */
298
+ export async function revertSiteScript(revisionId: string): Promise<SettingsActionResult> {
299
+ const { supabase, userId, error: authError } = await requireAdmin();
300
+ if (authError) return { ok: false, error: authError };
301
+
302
+ const { data: revisionRow, error: revisionError } = await supabase
303
+ .from('site_script_revisions')
304
+ .select(SITE_SCRIPT_REVISION_COLUMNS)
305
+ .eq('id', revisionId)
306
+ .maybeSingle();
307
+
308
+ if (revisionError || !revisionRow) {
309
+ return { ok: false, error: 'That revision no longer exists.' };
310
+ }
311
+
312
+ const revision = revisionRow as unknown as SiteScriptRevision;
313
+ const snapshot = buildSiteScriptSnapshot(revision.snapshot as unknown as Record<string, unknown>);
314
+
315
+ const existing = revision.script_id ? await readScript(supabase, revision.script_id) : null;
316
+
317
+ if (existing) {
318
+ const { error } = await supabase
319
+ .from('site_scripts')
320
+ .update(snapshot as never)
321
+ .eq('id', revision.script_id as string);
322
+
323
+ if (error) {
324
+ return { ok: false, error: `Failed to restore the script: ${error.message}` };
325
+ }
326
+ } else {
327
+ const { error } = await supabase.from('site_scripts').insert(snapshot as never);
328
+
329
+ if (error) {
330
+ return { ok: false, error: `Failed to recreate the script: ${error.message}` };
331
+ }
332
+ }
333
+
334
+ await recordRevision(supabase, {
335
+ actorUserId: userId,
336
+ revisionType: 'revert',
337
+ scriptId: revision.script_id,
338
+ snapshot,
339
+ summary: `Restored “${snapshot.name}” to the version from ${new Date(
340
+ revision.created_at
341
+ ).toISOString()}`,
342
+ });
343
+
344
+ revalidateSiteScripts();
345
+ return { ok: true, message: existing ? 'Script restored.' : 'Script recreated from history.' };
346
+ }