create-nextblock 0.11.1 → 0.11.2
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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/actions/interactions.test.ts +301 -0
- package/templates/nextblock-template/app/actions/interactions.ts +372 -0
- package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +4 -4
- package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +2 -2
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +56 -57
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +1 -1
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +837 -0
- package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +6 -0
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +4 -0
- package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +7 -2
- package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +4 -0
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +408 -0
- package/templates/nextblock-template/app/cms/interactions/page.tsx +51 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +4 -3
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +3 -5
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +1 -1
- package/templates/nextblock-template/app/page.tsx +2 -2
- package/templates/nextblock-template/app/product/[slug]/page.tsx +2 -0
- package/templates/nextblock-template/components/AppShell.tsx +1 -1
- package/templates/nextblock-template/components/PostCommentsSection.tsx +369 -0
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +419 -0
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +2 -0
- package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +62 -19
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +19 -19
- package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +4 -4
- package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +2 -0
- package/templates/nextblock-template/lib/setup/actions.ts +3 -1
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +30 -0
- package/templates/nextblock-template/lib/updates/check-upstream.ts +38 -4
- package/templates/nextblock-template/package.json +2 -1
- package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +2 -4
- package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +1 -1
- package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +1 -1
- package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
- package/templates/nextblock-template/lib/ai-block-generation.ts +0 -339
- package/templates/nextblock-template/lib/ai-client.ts +0 -247
- package/templates/nextblock-template/lib/ai-config.ts +0 -98
- package/templates/nextblock-template/lib/ai-cortex-widget-builder.ts +0 -125
- package/templates/nextblock-template/lib/ai-global-agent-custom-block-tools.ts +0 -363
- package/templates/nextblock-template/lib/ai-global-agent-db-tools.test.ts +0 -405
- package/templates/nextblock-template/lib/ai-global-agent-db-tools.ts +0 -1228
- package/templates/nextblock-template/lib/ai-global-agent-ecommerce.ts +0 -5
- package/templates/nextblock-template/lib/ai-global-agent-tools-stats.test.ts +0 -223
- package/templates/nextblock-template/lib/ai-global-agent-tools.test.ts +0 -2183
- package/templates/nextblock-template/lib/ai-global-agent-tools.ts +0 -4807
- package/templates/nextblock-template/lib/ai-key-crypto.test.ts +0 -70
- package/templates/nextblock-template/lib/ai-key-crypto.ts +0 -132
- package/templates/nextblock-template/lib/ai-model-catalog.test.ts +0 -49
- package/templates/nextblock-template/lib/ai-model-catalog.ts +0 -41
- package/templates/nextblock-template/lib/ai-model-registry.test.ts +0 -231
- package/templates/nextblock-template/lib/ai-model-registry.ts +0 -522
- package/templates/nextblock-template/lib/cortex-widget-registry.test.ts +0 -199
- package/templates/nextblock-template/lib/cortex-widget-registry.ts +0 -88
- package/templates/nextblock-template/lib/cortex-widget-schema.test.tsx +0 -237
- package/templates/nextblock-template/lib/cortex-widget-schema.ts +0 -393
package/package.json
CHANGED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const cacheMocks = vi.hoisted(() => ({
|
|
4
|
+
revalidatePath: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
const dbServerMocks = vi.hoisted(() => ({
|
|
8
|
+
createClient: vi.fn(),
|
|
9
|
+
getServiceRoleSupabaseClient: vi.fn(),
|
|
10
|
+
getProfileWithRoleServerSide: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const headersMocks = vi.hoisted(() => {
|
|
14
|
+
const get = vi.fn();
|
|
15
|
+
const set = vi.fn();
|
|
16
|
+
return {
|
|
17
|
+
cookies: vi.fn().mockResolvedValue({ get, set }),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock("next/cache", () => cacheMocks);
|
|
22
|
+
vi.mock("next/headers", () => headersMocks);
|
|
23
|
+
vi.mock("@nextblock-cms/db/server", () => dbServerMocks);
|
|
24
|
+
vi.mock("server-only", () => ({}));
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
submitInteraction,
|
|
28
|
+
toggleReaction,
|
|
29
|
+
updateInteractionStatus,
|
|
30
|
+
getNotificationEmails,
|
|
31
|
+
saveNotificationEmails,
|
|
32
|
+
} from "./interactions";
|
|
33
|
+
|
|
34
|
+
describe("Interactions Server Actions", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("submitInteraction", () => {
|
|
40
|
+
it("returns an error if the user is not logged in", async () => {
|
|
41
|
+
dbServerMocks.createClient.mockReturnValue({
|
|
42
|
+
auth: {
|
|
43
|
+
getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null }),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const res = await submitInteraction({
|
|
48
|
+
type: "review",
|
|
49
|
+
content: "Great product!",
|
|
50
|
+
rating: 5,
|
|
51
|
+
productId: "prod-1",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(res).toEqual({ error: "You must be logged in to submit a review or comment." });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("inserts a new review successfully and triggers revalidation", async () => {
|
|
58
|
+
const singleMock = vi.fn().mockResolvedValue({
|
|
59
|
+
data: { id: "int-1", type: "review", status: "pending" },
|
|
60
|
+
error: null,
|
|
61
|
+
});
|
|
62
|
+
const selectMock = vi.fn(() => ({ single: singleMock }));
|
|
63
|
+
const insertMock = vi.fn(() => ({ select: selectMock }));
|
|
64
|
+
const fromMock = vi.fn(() => ({ insert: insertMock }));
|
|
65
|
+
|
|
66
|
+
dbServerMocks.createClient.mockReturnValue({
|
|
67
|
+
auth: {
|
|
68
|
+
getUser: vi.fn().mockResolvedValue({
|
|
69
|
+
data: { user: { id: "user-1" } },
|
|
70
|
+
error: null,
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
from: fromMock,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const res = await submitInteraction({
|
|
77
|
+
type: "review",
|
|
78
|
+
content: "Awesome tool, works perfectly!",
|
|
79
|
+
rating: 5,
|
|
80
|
+
productId: "prod-1",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(res.success).toBe(true);
|
|
84
|
+
expect(fromMock).toHaveBeenCalledWith("cms_interactions");
|
|
85
|
+
expect(insertMock).toHaveBeenCalledWith({
|
|
86
|
+
type: "review",
|
|
87
|
+
status: "pending",
|
|
88
|
+
content: "Awesome tool, works perfectly!",
|
|
89
|
+
rating: 5,
|
|
90
|
+
user_id: "user-1",
|
|
91
|
+
product_id: "prod-1",
|
|
92
|
+
post_id: null,
|
|
93
|
+
reactions: {},
|
|
94
|
+
});
|
|
95
|
+
expect(cacheMocks.revalidatePath).toHaveBeenCalledWith("/cms/interactions");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("toggleReaction", () => {
|
|
100
|
+
it("toggles a reaction and updates cookies", async () => {
|
|
101
|
+
const getCookieMock = vi.fn().mockReturnValue(undefined);
|
|
102
|
+
const setCookieMock = vi.fn();
|
|
103
|
+
headersMocks.cookies.mockResolvedValue({
|
|
104
|
+
get: getCookieMock,
|
|
105
|
+
set: setCookieMock,
|
|
106
|
+
} as any);
|
|
107
|
+
|
|
108
|
+
const singleMock = vi.fn().mockResolvedValue({
|
|
109
|
+
data: {
|
|
110
|
+
reactions: { likes: 3 },
|
|
111
|
+
type: "review",
|
|
112
|
+
product_id: "prod-1",
|
|
113
|
+
post_id: null,
|
|
114
|
+
products: { slug: "my-product" },
|
|
115
|
+
},
|
|
116
|
+
error: null,
|
|
117
|
+
});
|
|
118
|
+
const eqSelectMock = vi.fn().mockReturnValue({ single: singleMock });
|
|
119
|
+
const selectMock = vi.fn().mockReturnValue({ eq: eqSelectMock });
|
|
120
|
+
const eqUpdateMock = vi.fn().mockResolvedValue({ error: null });
|
|
121
|
+
const updateMock = vi.fn().mockReturnValue({ eq: eqUpdateMock });
|
|
122
|
+
const fromMock = vi.fn().mockReturnValue({
|
|
123
|
+
select: selectMock,
|
|
124
|
+
update: updateMock,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
dbServerMocks.getServiceRoleSupabaseClient.mockReturnValue({
|
|
128
|
+
from: fromMock,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const res = await toggleReaction("int-1");
|
|
132
|
+
|
|
133
|
+
expect(res.success).toBe(true);
|
|
134
|
+
expect(res.count).toBe(4);
|
|
135
|
+
expect(res.hasReacted).toBe(true);
|
|
136
|
+
expect(updateMock).toHaveBeenCalledWith({ reactions: { likes: 4 } });
|
|
137
|
+
expect(eqUpdateMock).toHaveBeenCalledWith("id", "int-1");
|
|
138
|
+
expect(setCookieMock).toHaveBeenCalledWith(
|
|
139
|
+
"reacted_interactions",
|
|
140
|
+
JSON.stringify(["int-1"]),
|
|
141
|
+
expect.any(Object)
|
|
142
|
+
);
|
|
143
|
+
expect(cacheMocks.revalidatePath).toHaveBeenCalledWith("/product/my-product");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("updateInteractionStatus", () => {
|
|
148
|
+
it("returns error if non-admin attempts moderation", async () => {
|
|
149
|
+
dbServerMocks.createClient.mockReturnValue({
|
|
150
|
+
auth: {
|
|
151
|
+
getUser: vi.fn().mockResolvedValue({
|
|
152
|
+
data: { user: { id: "user-1" } },
|
|
153
|
+
error: null,
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
dbServerMocks.getProfileWithRoleServerSide.mockResolvedValue({
|
|
158
|
+
role: "WRITER",
|
|
159
|
+
} as any);
|
|
160
|
+
|
|
161
|
+
const res = await updateInteractionStatus("int-1", "approved");
|
|
162
|
+
expect(res.error).toContain("Unauthorized");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("allows admins to approve and triggers path revalidation", async () => {
|
|
166
|
+
dbServerMocks.createClient.mockReturnValue({
|
|
167
|
+
auth: {
|
|
168
|
+
getUser: vi.fn().mockResolvedValue({
|
|
169
|
+
data: { user: { id: "admin-1" } },
|
|
170
|
+
error: null,
|
|
171
|
+
}),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
dbServerMocks.getProfileWithRoleServerSide.mockResolvedValue({
|
|
175
|
+
role: "ADMIN",
|
|
176
|
+
} as any);
|
|
177
|
+
|
|
178
|
+
const singleMock = vi.fn().mockResolvedValue({
|
|
179
|
+
data: {
|
|
180
|
+
product_id: "prod-1",
|
|
181
|
+
post_id: null,
|
|
182
|
+
products: { slug: "my-product" },
|
|
183
|
+
},
|
|
184
|
+
error: null,
|
|
185
|
+
});
|
|
186
|
+
const eqSelectMock = vi.fn().mockReturnValue({ single: singleMock });
|
|
187
|
+
const selectMock = vi.fn().mockReturnValue({ eq: eqSelectMock });
|
|
188
|
+
const eqUpdateMock = vi.fn().mockResolvedValue({ error: null });
|
|
189
|
+
const updateMock = vi.fn().mockReturnValue({ eq: eqUpdateMock });
|
|
190
|
+
const fromMock = vi.fn().mockReturnValue({
|
|
191
|
+
select: selectMock,
|
|
192
|
+
update: updateMock,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
dbServerMocks.getServiceRoleSupabaseClient.mockReturnValue({
|
|
196
|
+
from: fromMock,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const res = await updateInteractionStatus("int-1", "approved");
|
|
200
|
+
|
|
201
|
+
expect(res.success).toBe(true);
|
|
202
|
+
expect(updateMock).toHaveBeenCalledWith({ status: "approved" });
|
|
203
|
+
expect(eqUpdateMock).toHaveBeenCalledWith("id", "int-1");
|
|
204
|
+
expect(cacheMocks.revalidatePath).toHaveBeenCalledWith("/product/my-product");
|
|
205
|
+
expect(cacheMocks.revalidatePath).toHaveBeenCalledWith("/cms/interactions");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("Notification Emails Settings Actions", () => {
|
|
210
|
+
it("returns error if non-admin attempts to get or save configuration", async () => {
|
|
211
|
+
dbServerMocks.createClient.mockReturnValue({
|
|
212
|
+
auth: {
|
|
213
|
+
getUser: vi.fn().mockResolvedValue({
|
|
214
|
+
data: { user: { id: "user-1" } },
|
|
215
|
+
error: null,
|
|
216
|
+
}),
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
dbServerMocks.getProfileWithRoleServerSide.mockResolvedValue({
|
|
220
|
+
role: "WRITER",
|
|
221
|
+
} as any);
|
|
222
|
+
|
|
223
|
+
const getRes = await getNotificationEmails();
|
|
224
|
+
expect(getRes.error).toContain("Unauthorized");
|
|
225
|
+
|
|
226
|
+
const saveRes = await saveNotificationEmails("test@example.com");
|
|
227
|
+
expect(saveRes.error).toContain("Unauthorized");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("allows admins to get configuration", async () => {
|
|
231
|
+
dbServerMocks.createClient.mockReturnValue({
|
|
232
|
+
auth: {
|
|
233
|
+
getUser: vi.fn().mockResolvedValue({
|
|
234
|
+
data: { user: { id: "admin-1" } },
|
|
235
|
+
error: null,
|
|
236
|
+
}),
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
dbServerMocks.getProfileWithRoleServerSide.mockResolvedValue({
|
|
240
|
+
role: "ADMIN",
|
|
241
|
+
} as any);
|
|
242
|
+
|
|
243
|
+
const maybeSingleMock = vi.fn().mockResolvedValue({
|
|
244
|
+
data: { value: { emails: "a@b.com, c@d.com" } },
|
|
245
|
+
error: null,
|
|
246
|
+
});
|
|
247
|
+
const eqMock = vi.fn().mockReturnValue({ maybeSingle: maybeSingleMock });
|
|
248
|
+
const selectMock = vi.fn().mockReturnValue({ eq: eqMock });
|
|
249
|
+
const fromMock = vi.fn().mockReturnValue({ select: selectMock });
|
|
250
|
+
|
|
251
|
+
dbServerMocks.createClient.mockReturnValue({
|
|
252
|
+
auth: {
|
|
253
|
+
getUser: vi.fn().mockResolvedValue({
|
|
254
|
+
data: { user: { id: "admin-1" } },
|
|
255
|
+
error: null,
|
|
256
|
+
}),
|
|
257
|
+
},
|
|
258
|
+
from: fromMock,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const res = await getNotificationEmails();
|
|
262
|
+
expect(res.success).toBe(true);
|
|
263
|
+
expect(res.emails).toBe("a@b.com, c@d.com");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("allows admins to save configuration", async () => {
|
|
267
|
+
dbServerMocks.createClient.mockReturnValue({
|
|
268
|
+
auth: {
|
|
269
|
+
getUser: vi.fn().mockResolvedValue({
|
|
270
|
+
data: { user: { id: "admin-1" } },
|
|
271
|
+
error: null,
|
|
272
|
+
}),
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
dbServerMocks.getProfileWithRoleServerSide.mockResolvedValue({
|
|
276
|
+
role: "ADMIN",
|
|
277
|
+
} as any);
|
|
278
|
+
|
|
279
|
+
const upsertMock = vi.fn().mockResolvedValue({ error: null });
|
|
280
|
+
const fromMock = vi.fn().mockReturnValue({ upsert: upsertMock });
|
|
281
|
+
|
|
282
|
+
dbServerMocks.createClient.mockReturnValue({
|
|
283
|
+
auth: {
|
|
284
|
+
getUser: vi.fn().mockResolvedValue({
|
|
285
|
+
data: { user: { id: "admin-1" } },
|
|
286
|
+
error: null,
|
|
287
|
+
}),
|
|
288
|
+
},
|
|
289
|
+
from: fromMock,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const res = await saveNotificationEmails(" a@b.com , c@d.com ");
|
|
293
|
+
expect(res.success).toBe(true);
|
|
294
|
+
expect(res.emails).toBe("a@b.com, c@d.com");
|
|
295
|
+
expect(upsertMock).toHaveBeenCalledWith({
|
|
296
|
+
key: "interactions_notification_emails",
|
|
297
|
+
value: { emails: "a@b.com, c@d.com" },
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { createClient, getServiceRoleSupabaseClient, getProfileWithRoleServerSide } from "@nextblock-cms/db/server";
|
|
4
|
+
import { revalidatePath } from "next/cache";
|
|
5
|
+
import { cookies, headers } from "next/headers";
|
|
6
|
+
import { sendEmail } from "./email";
|
|
7
|
+
|
|
8
|
+
export interface SubmitInteractionInput {
|
|
9
|
+
type: "review" | "comment";
|
|
10
|
+
content: string;
|
|
11
|
+
rating?: number;
|
|
12
|
+
productId?: string;
|
|
13
|
+
postId?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Submits a new comment or review. Default status is 'pending' for moderation.
|
|
18
|
+
*/
|
|
19
|
+
export async function submitInteraction(input: SubmitInteractionInput) {
|
|
20
|
+
const supabase = createClient();
|
|
21
|
+
|
|
22
|
+
// 1. Authenticate user
|
|
23
|
+
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
24
|
+
if (authError || !user) {
|
|
25
|
+
return { error: "You must be logged in to submit a review or comment." };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 2. Validate inputs
|
|
29
|
+
if (!input.content || input.content.trim().length < 5) {
|
|
30
|
+
return { error: "Content must be at least 5 characters long." };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (input.type === "review") {
|
|
34
|
+
if (!input.productId) {
|
|
35
|
+
return { error: "Product ID is required for a review." };
|
|
36
|
+
}
|
|
37
|
+
if (!input.rating || input.rating < 1 || input.rating > 5) {
|
|
38
|
+
return { error: "Rating must be between 1 and 5 stars." };
|
|
39
|
+
}
|
|
40
|
+
} else if (input.type === "comment") {
|
|
41
|
+
if (!input.postId) {
|
|
42
|
+
return { error: "Post ID is required for a comment." };
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
return { error: "Invalid interaction type." };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// 3. Insert interaction
|
|
50
|
+
const { data, error } = await supabase
|
|
51
|
+
.from("cms_interactions" as any)
|
|
52
|
+
.insert({
|
|
53
|
+
type: input.type,
|
|
54
|
+
status: "pending",
|
|
55
|
+
content: input.content.trim(),
|
|
56
|
+
rating: input.type === "review" ? input.rating : null,
|
|
57
|
+
user_id: user.id,
|
|
58
|
+
product_id: input.type === "review" ? input.productId : null,
|
|
59
|
+
post_id: input.type === "comment" ? input.postId : null,
|
|
60
|
+
reactions: {},
|
|
61
|
+
})
|
|
62
|
+
.select()
|
|
63
|
+
.single();
|
|
64
|
+
|
|
65
|
+
if (error) {
|
|
66
|
+
console.error("Error inserting interaction:", error);
|
|
67
|
+
return { error: `Failed to submit: ${error.message}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 4. Revalidate moderation panel path
|
|
71
|
+
revalidatePath("/cms/interactions");
|
|
72
|
+
|
|
73
|
+
// 5. Send email notification asynchronously if emails are configured
|
|
74
|
+
try {
|
|
75
|
+
const admin = getServiceRoleSupabaseClient();
|
|
76
|
+
if (admin) {
|
|
77
|
+
const { data: config } = await admin
|
|
78
|
+
.from("site_settings")
|
|
79
|
+
.select("value")
|
|
80
|
+
.eq("key", "interactions_notification_emails")
|
|
81
|
+
.maybeSingle();
|
|
82
|
+
|
|
83
|
+
const emailsString = (config?.value as any)?.emails || "";
|
|
84
|
+
|
|
85
|
+
if (emailsString) {
|
|
86
|
+
const host = (await headers()).get("host");
|
|
87
|
+
const protocol = host?.includes("localhost") || host?.includes("127.0.0.1") ? "http" : "https";
|
|
88
|
+
const origin = `${protocol}://${host}`;
|
|
89
|
+
|
|
90
|
+
const capitalizedType = input.type.charAt(0).toUpperCase() + input.type.slice(1);
|
|
91
|
+
const subject = `[NextBlock CMS] New Pending ${capitalizedType} Submitted`;
|
|
92
|
+
|
|
93
|
+
const html = `
|
|
94
|
+
<div style="font-family: sans-serif; padding: 20px; color: #333; max-width: 600px; margin: 0 auto; border: 1px solid #eee; border-radius: 8px;">
|
|
95
|
+
<h2 style="color: #6366f1; margin-top: 0;">New Pending ${capitalizedType} Submitted</h2>
|
|
96
|
+
<p>Hello,</p>
|
|
97
|
+
<p>A new content interaction has been submitted and is currently <strong>pending moderation</strong>.</p>
|
|
98
|
+
<hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
|
|
99
|
+
<div style="background-color: #f9fafb; padding: 15px; border-radius: 6px; margin-bottom: 20px;">
|
|
100
|
+
<p style="margin: 0 0 8px 0;"><strong>Type:</strong> ${capitalizedType}</p>
|
|
101
|
+
${input.type === "review" && input.rating ? `<p style="margin: 0 0 8px 0;"><strong>Rating:</strong> ${input.rating} / 5</p>` : ""}
|
|
102
|
+
<p style="margin: 0 0 8px 0;"><strong>Content:</strong></p>
|
|
103
|
+
<blockquote style="margin: 0; padding-left: 10px; border-left: 3px solid #6366f1; color: #555; font-style: italic;">
|
|
104
|
+
${input.content.trim()}
|
|
105
|
+
</blockquote>
|
|
106
|
+
</div>
|
|
107
|
+
<p>Please log in to the moderation dashboard to approve or deny this interaction:</p>
|
|
108
|
+
<p style="margin-top: 20px;">
|
|
109
|
+
<a href="${origin}/cms/interactions" style="background-color: #6366f1; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">
|
|
110
|
+
Go to Moderation Dashboard
|
|
111
|
+
</a>
|
|
112
|
+
</p>
|
|
113
|
+
<hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
|
|
114
|
+
<p style="font-size: 11px; color: #888;">This is an automated notification from your NextBlock™ CMS instance.</p>
|
|
115
|
+
</div>
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
const text = `
|
|
119
|
+
New Pending ${capitalizedType} Submitted
|
|
120
|
+
|
|
121
|
+
A new ${input.type} has been submitted and is currently pending moderation.
|
|
122
|
+
|
|
123
|
+
Type: ${capitalizedType}
|
|
124
|
+
${input.type === "review" && input.rating ? `Rating: ${input.rating} / 5\n` : ""}
|
|
125
|
+
Content: "${input.content.trim()}"
|
|
126
|
+
|
|
127
|
+
Moderation Dashboard: ${origin}/cms/interactions
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
sendEmail({
|
|
131
|
+
to: emailsString,
|
|
132
|
+
subject,
|
|
133
|
+
text,
|
|
134
|
+
html,
|
|
135
|
+
}).catch((err) =>
|
|
136
|
+
console.error("Failed to send pending interaction email notification:", err)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (emailErr) {
|
|
141
|
+
console.error("Failed to process email notifications:", emailErr);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { success: true, data };
|
|
145
|
+
} catch (err: any) {
|
|
146
|
+
console.error("Submit interaction failed:", err);
|
|
147
|
+
return { error: err.message || "An unexpected error occurred." };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Toggles a reaction (like) on a comment or review. Rate-limited and validated using cookies.
|
|
153
|
+
*/
|
|
154
|
+
export async function toggleReaction(interactionId: string, reactionType = "likes") {
|
|
155
|
+
if (!interactionId) return { error: "Interaction ID is required." };
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// Rate limit / duplicate prevention using cookies
|
|
159
|
+
const cookieStore = await cookies();
|
|
160
|
+
const reactedCookie = cookieStore.get("reacted_interactions")?.value;
|
|
161
|
+
let reactedList: string[] = [];
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
if (reactedCookie) {
|
|
165
|
+
reactedList = JSON.parse(reactedCookie);
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
reactedList = [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const hasReacted = reactedList.includes(interactionId);
|
|
172
|
+
|
|
173
|
+
// Call service role client since visitors don't have update RLS policies
|
|
174
|
+
const admin = getServiceRoleSupabaseClient();
|
|
175
|
+
|
|
176
|
+
// Fetch current reactions
|
|
177
|
+
const { data: interaction, error: fetchError } = await admin
|
|
178
|
+
.from("cms_interactions")
|
|
179
|
+
.select("reactions, type, product_id, post_id, products(slug), posts(slug)")
|
|
180
|
+
.eq("id", interactionId)
|
|
181
|
+
.single();
|
|
182
|
+
|
|
183
|
+
if (fetchError || !interaction) {
|
|
184
|
+
return { error: "Interaction not found." };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const reactions = (interaction.reactions as Record<string, number>) || {};
|
|
188
|
+
const currentCount = reactions[reactionType] || 0;
|
|
189
|
+
const newCount = hasReacted ? Math.max(0, currentCount - 1) : currentCount + 1;
|
|
190
|
+
reactions[reactionType] = newCount;
|
|
191
|
+
|
|
192
|
+
// Save back to db
|
|
193
|
+
const { error: updateError } = await admin
|
|
194
|
+
.from("cms_interactions")
|
|
195
|
+
.update({ reactions })
|
|
196
|
+
.eq("id", interactionId);
|
|
197
|
+
|
|
198
|
+
if (updateError) {
|
|
199
|
+
console.error("Error updating reactions:", updateError);
|
|
200
|
+
return { error: "Failed to update reaction." };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Update the cookie
|
|
204
|
+
if (hasReacted) {
|
|
205
|
+
reactedList = reactedList.filter(id => id !== interactionId);
|
|
206
|
+
} else {
|
|
207
|
+
reactedList.push(interactionId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
cookieStore.set("reacted_interactions", JSON.stringify(reactedList), {
|
|
211
|
+
maxAge: 60 * 60 * 24 * 365, // 1 year
|
|
212
|
+
httpOnly: true,
|
|
213
|
+
path: "/",
|
|
214
|
+
sameSite: "lax",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Revalidate paths to reflect reaction count updates
|
|
218
|
+
const resolvedProduct = interaction.products as any;
|
|
219
|
+
const resolvedPost = interaction.posts as any;
|
|
220
|
+
|
|
221
|
+
if (interaction.product_id && resolvedProduct?.slug) {
|
|
222
|
+
revalidatePath(`/product/${resolvedProduct.slug}`);
|
|
223
|
+
} else if (interaction.post_id && resolvedPost?.slug) {
|
|
224
|
+
revalidatePath(`/article/${resolvedPost.slug}`);
|
|
225
|
+
}
|
|
226
|
+
revalidatePath("/cms/interactions");
|
|
227
|
+
|
|
228
|
+
return { success: true, count: newCount, hasReacted: !hasReacted };
|
|
229
|
+
} catch (err: any) {
|
|
230
|
+
console.error("Toggle reaction failed:", err);
|
|
231
|
+
return { error: err.message || "An unexpected error occurred." };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Updates an interaction's status (approved or denied). Admin/Moderator only.
|
|
237
|
+
*/
|
|
238
|
+
export async function updateInteractionStatus(interactionId: string, status: "approved" | "denied") {
|
|
239
|
+
const supabase = createClient();
|
|
240
|
+
|
|
241
|
+
// 1. Authenticate user
|
|
242
|
+
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
243
|
+
if (authError || !user) {
|
|
244
|
+
return { error: "Not authenticated" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 2. Authorize as Admin or Writer
|
|
248
|
+
const profile = await getProfileWithRoleServerSide(user.id);
|
|
249
|
+
if (!profile || (profile.role !== "ADMIN" && profile.role !== "WRITER")) {
|
|
250
|
+
return { error: "Unauthorized. Admin or Writer permissions required." };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 3. Admin-only rule for denying/approving if strict
|
|
254
|
+
if (profile.role !== "ADMIN") {
|
|
255
|
+
// If writers are not allowed to moderate, block it. The spec says:
|
|
256
|
+
// "Admin-only permission action to switch states between approved or denied."
|
|
257
|
+
// So let's enforce STRICT Admin only for status updates.
|
|
258
|
+
return { error: "Unauthorized. Admin permissions required to moderate." };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const admin = getServiceRoleSupabaseClient();
|
|
263
|
+
|
|
264
|
+
// Fetch interaction details for path revalidation
|
|
265
|
+
const { data: interaction, error: fetchError } = await admin
|
|
266
|
+
.from("cms_interactions")
|
|
267
|
+
.select("product_id, post_id, products(slug), posts(slug)")
|
|
268
|
+
.eq("id", interactionId)
|
|
269
|
+
.single();
|
|
270
|
+
|
|
271
|
+
if (fetchError || !interaction) {
|
|
272
|
+
return { error: "Interaction not found." };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 4. Update status
|
|
276
|
+
const { error: updateError } = await admin
|
|
277
|
+
.from("cms_interactions")
|
|
278
|
+
.update({ status })
|
|
279
|
+
.eq("id", interactionId);
|
|
280
|
+
|
|
281
|
+
if (updateError) {
|
|
282
|
+
console.error("Error updating status:", updateError);
|
|
283
|
+
return { error: `Failed to update status: ${updateError.message}` };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 5. Revalidate paths
|
|
287
|
+
const resolvedProduct = interaction.products as any;
|
|
288
|
+
const resolvedPost = interaction.posts as any;
|
|
289
|
+
|
|
290
|
+
if (interaction.product_id && resolvedProduct?.slug) {
|
|
291
|
+
revalidatePath(`/product/${resolvedProduct.slug}`);
|
|
292
|
+
} else if (interaction.post_id && resolvedPost?.slug) {
|
|
293
|
+
revalidatePath(`/article/${resolvedPost.slug}`);
|
|
294
|
+
}
|
|
295
|
+
revalidatePath("/cms/interactions");
|
|
296
|
+
|
|
297
|
+
return { success: true };
|
|
298
|
+
} catch (err: any) {
|
|
299
|
+
console.error("Update interaction status failed:", err);
|
|
300
|
+
return { error: err.message || "An unexpected error occurred." };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Fetches the interactions notification emails from site_settings.
|
|
306
|
+
*/
|
|
307
|
+
export async function getNotificationEmails() {
|
|
308
|
+
const supabase = createClient();
|
|
309
|
+
|
|
310
|
+
// Authenticate & authorize
|
|
311
|
+
const { data: { user } } = await supabase.auth.getUser();
|
|
312
|
+
if (!user) return { error: "Not authenticated" };
|
|
313
|
+
|
|
314
|
+
const profile = await getProfileWithRoleServerSide(user.id);
|
|
315
|
+
if (!profile || profile.role !== "ADMIN") {
|
|
316
|
+
return { error: "Unauthorized. Admin role required." };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const { data, error } = await supabase
|
|
321
|
+
.from("site_settings")
|
|
322
|
+
.select("value")
|
|
323
|
+
.eq("key", "interactions_notification_emails")
|
|
324
|
+
.maybeSingle();
|
|
325
|
+
|
|
326
|
+
if (error) throw error;
|
|
327
|
+
|
|
328
|
+
return { success: true, emails: (data?.value as any)?.emails || "" };
|
|
329
|
+
} catch (err: any) {
|
|
330
|
+
console.error("Failed to fetch notification emails:", err);
|
|
331
|
+
return { error: err.message || "Failed to fetch settings." };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Saves the interactions notification emails to site_settings.
|
|
337
|
+
*/
|
|
338
|
+
export async function saveNotificationEmails(emails: string) {
|
|
339
|
+
const supabase = createClient();
|
|
340
|
+
|
|
341
|
+
// Authenticate & authorize
|
|
342
|
+
const { data: { user } } = await supabase.auth.getUser();
|
|
343
|
+
if (!user) return { error: "Not authenticated" };
|
|
344
|
+
|
|
345
|
+
const profile = await getProfileWithRoleServerSide(user.id);
|
|
346
|
+
if (!profile || profile.role !== "ADMIN") {
|
|
347
|
+
return { error: "Unauthorized. Admin role required." };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Basic validation of emails
|
|
351
|
+
const cleaned = emails
|
|
352
|
+
.split(",")
|
|
353
|
+
.map((e) => e.trim())
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.join(", ");
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const { error } = await supabase
|
|
359
|
+
.from("site_settings")
|
|
360
|
+
.upsert({
|
|
361
|
+
key: "interactions_notification_emails",
|
|
362
|
+
value: { emails: cleaned },
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (error) throw error;
|
|
366
|
+
|
|
367
|
+
return { success: true, emails: cleaned };
|
|
368
|
+
} catch (err: any) {
|
|
369
|
+
console.error("Failed to save notification emails:", err);
|
|
370
|
+
return { error: err.message || "Failed to save settings." };
|
|
371
|
+
}
|
|
372
|
+
}
|