@anthusai/papyrus 5.20.0 → 5.22.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.
- package/amplify/data/schema.js +11 -25
- package/amplify/data/schema.ts +13 -26
- package/amplify/functions/content-actions/handler.py +17 -2
- package/amplify/plugins/newsletter/config.js +195 -0
- package/amplify/plugins/newsletter/config.ts +240 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +260 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +52 -1
- package/amplify/plugins/reader-steering/config.ts +91 -1
- package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
- package/amplify/plugins/reader-steering/function/handler.py +829 -10
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/resource.js +1 -1
- package/amplify/plugins/reader-steering/function/resource.ts +1 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.js +40 -4
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +43 -4
- package/amplify/plugins/reader-steering/schema.js +101 -1
- package/amplify/plugins/reader-steering/schema.ts +101 -1
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +2 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/components/moderation-panel.tsx +248 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-organizer.css +14 -0
- package/components/topic-steering-workspace.tsx +42 -5
- package/lib/newsletter-api.ts +391 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsletter-pages.ts +264 -0
- package/lib/reader-api.ts +7 -5
- package/lib/reader-moderation.ts +220 -0
- package/package.json +19 -1
- package/routes.manifest.json +103 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { readerActions } from "./function/resource.js";
|
|
2
3
|
/**
|
|
3
4
|
* Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
|
|
4
5
|
* tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
|
|
5
|
-
* the siteSubject for a profile, the lowercase handle for a claim
|
|
6
|
+
* the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote and
|
|
7
|
+
* `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
|
|
6
8
|
*/
|
|
7
9
|
const staffOnly = (allow) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
|
|
8
10
|
export function readerSteeringSchemaDefinitions() {
|
|
@@ -17,6 +19,7 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
17
19
|
karma: a.integer(),
|
|
18
20
|
})
|
|
19
21
|
.disableOperations(["subscriptions"])
|
|
22
|
+
.secondaryIndexes((index) => [index("readerId").name("profilesByReader").queryField("listReaderProfilesByReader")])
|
|
20
23
|
.authorization(staffOnly),
|
|
21
24
|
ReaderHandle: a
|
|
22
25
|
.model({
|
|
@@ -36,5 +39,102 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
36
39
|
})
|
|
37
40
|
.disableOperations(["subscriptions"])
|
|
38
41
|
.authorization(staffOnly),
|
|
42
|
+
ReaderComment: a
|
|
43
|
+
.model({
|
|
44
|
+
id: a.id().required(),
|
|
45
|
+
subjectKey: a.string().required(),
|
|
46
|
+
readerId: a.string().required(),
|
|
47
|
+
body: a.string().required(),
|
|
48
|
+
parentId: a.string(),
|
|
49
|
+
ratingContext: a.json(),
|
|
50
|
+
state: a.string().required(),
|
|
51
|
+
signals: a.json(),
|
|
52
|
+
moderation: a.string().required(),
|
|
53
|
+
reportCount: a.integer(),
|
|
54
|
+
reviewedAt: a.datetime(),
|
|
55
|
+
editedAt: a.datetime(),
|
|
56
|
+
deletedAt: a.datetime(),
|
|
57
|
+
createdAt: a.datetime().required(),
|
|
58
|
+
})
|
|
59
|
+
.disableOperations(["subscriptions"])
|
|
60
|
+
.secondaryIndexes((index) => [index("moderation").sortKeys(["createdAt"]).name("commentsByModeration").queryField("listReaderCommentsByModerationAndCreatedAt")])
|
|
61
|
+
.authorization(staffOnly),
|
|
62
|
+
ReaderSubmission: a
|
|
63
|
+
.model({
|
|
64
|
+
id: a.id().required(),
|
|
65
|
+
canonicalUrl: a.string().required(),
|
|
66
|
+
domain: a.string().required(),
|
|
67
|
+
title: a.string().required(),
|
|
68
|
+
kind: a.string().required(),
|
|
69
|
+
note: a.string(),
|
|
70
|
+
readerId: a.string().required(),
|
|
71
|
+
state: a.string().required(),
|
|
72
|
+
similar: a.string().array(),
|
|
73
|
+
signals: a.json(),
|
|
74
|
+
extractionState: a.string().required(),
|
|
75
|
+
itemId: a.string(),
|
|
76
|
+
moderation: a.string().required(),
|
|
77
|
+
reportCount: a.integer(),
|
|
78
|
+
reviewedAt: a.datetime(),
|
|
79
|
+
createdAt: a.datetime().required(),
|
|
80
|
+
})
|
|
81
|
+
.disableOperations(["subscriptions"])
|
|
82
|
+
.secondaryIndexes((index) => [
|
|
83
|
+
index("domain").sortKeys(["createdAt"]).name("submissionsByDomain").queryField("listReaderSubmissionsByDomainAndCreatedAt"),
|
|
84
|
+
index("moderation").sortKeys(["createdAt"]).name("submissionsByModeration").queryField("listReaderSubmissionsByModerationAndCreatedAt"),
|
|
85
|
+
])
|
|
86
|
+
.authorization(staffOnly),
|
|
87
|
+
ReaderReport: a
|
|
88
|
+
.model({
|
|
89
|
+
id: a.id().required(),
|
|
90
|
+
targetType: a.string().required(),
|
|
91
|
+
target: a.string().required(),
|
|
92
|
+
reporterReaderId: a.string().required(),
|
|
93
|
+
reason: a.string().required(),
|
|
94
|
+
state: a.string().required(),
|
|
95
|
+
closedAt: a.datetime(),
|
|
96
|
+
createdAt: a.datetime().required(),
|
|
97
|
+
})
|
|
98
|
+
.disableOperations(["subscriptions"])
|
|
99
|
+
.secondaryIndexes((index) => [index("target").sortKeys(["createdAt"]).name("reportsByTarget").queryField("listReaderReportsByTargetAndCreatedAt")])
|
|
100
|
+
.authorization(staffOnly),
|
|
101
|
+
ModerationAction: a
|
|
102
|
+
.model({
|
|
103
|
+
id: a.id().required(),
|
|
104
|
+
by: a.string().required(),
|
|
105
|
+
actorSub: a.string().required(),
|
|
106
|
+
action: a.string().required(),
|
|
107
|
+
targetType: a.string().required(),
|
|
108
|
+
target: a.string().required(),
|
|
109
|
+
reason: a.string(),
|
|
110
|
+
fromState: a.string().required(),
|
|
111
|
+
toState: a.string().required(),
|
|
112
|
+
fromModeration: a.string(),
|
|
113
|
+
toModeration: a.string(),
|
|
114
|
+
undoOf: a.string(),
|
|
115
|
+
undoneBy: a.string(),
|
|
116
|
+
at: a.datetime().required(),
|
|
117
|
+
})
|
|
118
|
+
.disableOperations(["subscriptions"])
|
|
119
|
+
.secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
|
|
120
|
+
.authorization(staffOnly),
|
|
121
|
+
ModerationReceipt: a.customType({
|
|
122
|
+
status: a.string().required(),
|
|
123
|
+
id: a.string(),
|
|
124
|
+
error: a.string(),
|
|
125
|
+
message: a.string(),
|
|
126
|
+
}),
|
|
127
|
+
moderateReader: a
|
|
128
|
+
.mutation()
|
|
129
|
+
.arguments({
|
|
130
|
+
action: a.string().required(),
|
|
131
|
+
targetType: a.string().required(),
|
|
132
|
+
target: a.string().required(),
|
|
133
|
+
reason: a.string(),
|
|
134
|
+
undoOf: a.string(),
|
|
135
|
+
})
|
|
136
|
+
.returns(a.ref("ModerationReceipt"))
|
|
137
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
138
|
+
.handler(a.handler.function(readerActions)),
|
|
39
139
|
};
|
|
40
140
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { readerActions } from "./function/resource";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
|
|
5
6
|
* tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
|
|
6
|
-
* the siteSubject for a profile, the lowercase handle for a claim
|
|
7
|
+
* the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote and
|
|
8
|
+
* `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
|
|
7
9
|
*/
|
|
8
10
|
const staffOnly = (allow: any) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
|
|
9
11
|
|
|
@@ -19,6 +21,7 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
19
21
|
karma: a.integer(),
|
|
20
22
|
})
|
|
21
23
|
.disableOperations(["subscriptions"])
|
|
24
|
+
.secondaryIndexes((index) => [index("readerId").name("profilesByReader").queryField("listReaderProfilesByReader")])
|
|
22
25
|
.authorization(staffOnly),
|
|
23
26
|
ReaderHandle: a
|
|
24
27
|
.model({
|
|
@@ -38,5 +41,102 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
38
41
|
})
|
|
39
42
|
.disableOperations(["subscriptions"])
|
|
40
43
|
.authorization(staffOnly),
|
|
44
|
+
ReaderComment: a
|
|
45
|
+
.model({
|
|
46
|
+
id: a.id().required(),
|
|
47
|
+
subjectKey: a.string().required(),
|
|
48
|
+
readerId: a.string().required(),
|
|
49
|
+
body: a.string().required(),
|
|
50
|
+
parentId: a.string(),
|
|
51
|
+
ratingContext: a.json(),
|
|
52
|
+
state: a.string().required(),
|
|
53
|
+
signals: a.json(),
|
|
54
|
+
moderation: a.string().required(),
|
|
55
|
+
reportCount: a.integer(),
|
|
56
|
+
reviewedAt: a.datetime(),
|
|
57
|
+
editedAt: a.datetime(),
|
|
58
|
+
deletedAt: a.datetime(),
|
|
59
|
+
createdAt: a.datetime().required(),
|
|
60
|
+
})
|
|
61
|
+
.disableOperations(["subscriptions"])
|
|
62
|
+
.secondaryIndexes((index) => [index("moderation").sortKeys(["createdAt"]).name("commentsByModeration").queryField("listReaderCommentsByModerationAndCreatedAt")])
|
|
63
|
+
.authorization(staffOnly),
|
|
64
|
+
ReaderSubmission: a
|
|
65
|
+
.model({
|
|
66
|
+
id: a.id().required(),
|
|
67
|
+
canonicalUrl: a.string().required(),
|
|
68
|
+
domain: a.string().required(),
|
|
69
|
+
title: a.string().required(),
|
|
70
|
+
kind: a.string().required(),
|
|
71
|
+
note: a.string(),
|
|
72
|
+
readerId: a.string().required(),
|
|
73
|
+
state: a.string().required(),
|
|
74
|
+
similar: a.string().array(),
|
|
75
|
+
signals: a.json(),
|
|
76
|
+
extractionState: a.string().required(),
|
|
77
|
+
itemId: a.string(),
|
|
78
|
+
moderation: a.string().required(),
|
|
79
|
+
reportCount: a.integer(),
|
|
80
|
+
reviewedAt: a.datetime(),
|
|
81
|
+
createdAt: a.datetime().required(),
|
|
82
|
+
})
|
|
83
|
+
.disableOperations(["subscriptions"])
|
|
84
|
+
.secondaryIndexes((index) => [
|
|
85
|
+
index("domain").sortKeys(["createdAt"]).name("submissionsByDomain").queryField("listReaderSubmissionsByDomainAndCreatedAt"),
|
|
86
|
+
index("moderation").sortKeys(["createdAt"]).name("submissionsByModeration").queryField("listReaderSubmissionsByModerationAndCreatedAt"),
|
|
87
|
+
])
|
|
88
|
+
.authorization(staffOnly),
|
|
89
|
+
ReaderReport: a
|
|
90
|
+
.model({
|
|
91
|
+
id: a.id().required(),
|
|
92
|
+
targetType: a.string().required(),
|
|
93
|
+
target: a.string().required(),
|
|
94
|
+
reporterReaderId: a.string().required(),
|
|
95
|
+
reason: a.string().required(),
|
|
96
|
+
state: a.string().required(),
|
|
97
|
+
closedAt: a.datetime(),
|
|
98
|
+
createdAt: a.datetime().required(),
|
|
99
|
+
})
|
|
100
|
+
.disableOperations(["subscriptions"])
|
|
101
|
+
.secondaryIndexes((index) => [index("target").sortKeys(["createdAt"]).name("reportsByTarget").queryField("listReaderReportsByTargetAndCreatedAt")])
|
|
102
|
+
.authorization(staffOnly),
|
|
103
|
+
ModerationAction: a
|
|
104
|
+
.model({
|
|
105
|
+
id: a.id().required(),
|
|
106
|
+
by: a.string().required(),
|
|
107
|
+
actorSub: a.string().required(),
|
|
108
|
+
action: a.string().required(),
|
|
109
|
+
targetType: a.string().required(),
|
|
110
|
+
target: a.string().required(),
|
|
111
|
+
reason: a.string(),
|
|
112
|
+
fromState: a.string().required(),
|
|
113
|
+
toState: a.string().required(),
|
|
114
|
+
fromModeration: a.string(),
|
|
115
|
+
toModeration: a.string(),
|
|
116
|
+
undoOf: a.string(),
|
|
117
|
+
undoneBy: a.string(),
|
|
118
|
+
at: a.datetime().required(),
|
|
119
|
+
})
|
|
120
|
+
.disableOperations(["subscriptions"])
|
|
121
|
+
.secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
|
|
122
|
+
.authorization(staffOnly),
|
|
123
|
+
ModerationReceipt: a.customType({
|
|
124
|
+
status: a.string().required(),
|
|
125
|
+
id: a.string(),
|
|
126
|
+
error: a.string(),
|
|
127
|
+
message: a.string(),
|
|
128
|
+
}),
|
|
129
|
+
moderateReader: a
|
|
130
|
+
.mutation()
|
|
131
|
+
.arguments({
|
|
132
|
+
action: a.string().required(),
|
|
133
|
+
targetType: a.string().required(),
|
|
134
|
+
target: a.string().required(),
|
|
135
|
+
reason: a.string(),
|
|
136
|
+
undoOf: a.string(),
|
|
137
|
+
})
|
|
138
|
+
.returns(a.ref("ModerationReceipt"))
|
|
139
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
140
|
+
.handler(a.handler.function(readerActions)),
|
|
41
141
|
};
|
|
42
142
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { newsletterPlugin } from "./newsletter/papyrus.plugin.js";
|
|
1
2
|
import { readerSteeringPlugin } from "./reader-steering/papyrus.plugin.js";
|
|
2
3
|
import { signupPlugin } from "./signup/papyrus.plugin.js";
|
|
3
4
|
/** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
|
|
4
5
|
export const KNOWN_PLUGINS = {
|
|
6
|
+
newsletter: newsletterPlugin,
|
|
5
7
|
"reader-steering": readerSteeringPlugin,
|
|
6
8
|
signup: signupPlugin,
|
|
7
9
|
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { PapyrusPlugin, PluginRequest } from "./types";
|
|
2
|
+
import { newsletterPlugin } from "./newsletter/papyrus.plugin";
|
|
2
3
|
import { readerSteeringPlugin } from "./reader-steering/papyrus.plugin";
|
|
3
4
|
import { signupPlugin } from "./signup/papyrus.plugin";
|
|
4
5
|
|
|
5
6
|
/** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
|
|
6
7
|
export const KNOWN_PLUGINS: Record<string, PapyrusPlugin<any, any>> = {
|
|
8
|
+
newsletter: newsletterPlugin,
|
|
7
9
|
"reader-steering": readerSteeringPlugin,
|
|
8
10
|
signup: signupPlugin,
|
|
9
11
|
};
|
package/amplify/plugins/types.ts
CHANGED
|
@@ -10,6 +10,8 @@ export type PluginManifest = {
|
|
|
10
10
|
/** Public contract version. Bump it when a mutation input, a model field, an element name or an attribute changes. No compatibility shims. */
|
|
11
11
|
contract: number;
|
|
12
12
|
models: string[];
|
|
13
|
+
/** Custom queries it adds (operations that read). */
|
|
14
|
+
queries?: string[];
|
|
13
15
|
mutations: string[];
|
|
14
16
|
/** What an anonymous visitor may call. Everything else is closed to guests. */
|
|
15
17
|
guest: { mutations: string[] };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleNewsletterConfirmPage, handleNewsletterConfirmPost } from "../../../../lib/newsletter-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function GET(request: NextRequest) {
|
|
8
|
+
return handleNewsletterConfirmPage(request);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function POST(request: NextRequest) {
|
|
12
|
+
return handleNewsletterConfirmPost(request);
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleNewsletterPreferences } from "../../../../lib/newsletter-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function GET(request: NextRequest) {
|
|
8
|
+
return handleNewsletterPreferences(request);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function POST(request: NextRequest) {
|
|
12
|
+
return handleNewsletterPreferences(request);
|
|
13
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleNewsletterSubscribe } from "../../../../lib/newsletter-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleNewsletterSubscribe(request);
|
|
9
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleNewsletterUnsubscribe } from "../../../../lib/newsletter-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function GET(request: NextRequest) {
|
|
8
|
+
return handleNewsletterUnsubscribe(request);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function POST(request: NextRequest) {
|
|
12
|
+
return handleNewsletterUnsubscribe(request);
|
|
13
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRequest } from "../../../../../lib/reader-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleReaderRequest(request, "comment-delete");
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRequest } from "../../../../../lib/reader-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleReaderRequest(request, "comment-edit");
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRequest } from "../../../../lib/reader-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleReaderRequest(request, "comment");
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRequest } from "../../../../lib/reader-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleReaderRequest(request, "report");
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRequest } from "../../../../lib/reader-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleReaderRequest(request, "submit");
|
|
9
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
7
|
+
import {
|
|
8
|
+
buildActionLog,
|
|
9
|
+
buildHidden,
|
|
10
|
+
buildQueue,
|
|
11
|
+
moderationModeText,
|
|
12
|
+
receiptMessage,
|
|
13
|
+
shortReader,
|
|
14
|
+
type ActionEntry,
|
|
15
|
+
type ActionRow,
|
|
16
|
+
type CommentRow,
|
|
17
|
+
type ModerationActionName,
|
|
18
|
+
type ModerationRow,
|
|
19
|
+
type ModerationTarget,
|
|
20
|
+
type Receipt,
|
|
21
|
+
type ReportRow,
|
|
22
|
+
type SubmissionRow,
|
|
23
|
+
} from "../lib/reader-moderation";
|
|
24
|
+
|
|
25
|
+
const SUBMISSION_FIELDS = "id canonicalUrl domain title kind note readerId state moderation reportCount signals itemId createdAt";
|
|
26
|
+
const COMMENT_FIELDS = "id subjectKey readerId body parentId state moderation reportCount signals createdAt";
|
|
27
|
+
|
|
28
|
+
const QUEUE_SUBMISSIONS = `query Queue($nextToken: String) { listReaderSubmissionsByModerationAndCreatedAt(moderation: "new", sortDirection: DESC, limit: 100, nextToken: $nextToken) { items { ${SUBMISSION_FIELDS} } nextToken } }`;
|
|
29
|
+
const QUEUE_COMMENTS = `query Queue($nextToken: String) { listReaderCommentsByModerationAndCreatedAt(moderation: "new", sortDirection: DESC, limit: 100, nextToken: $nextToken) { items { ${COMMENT_FIELDS} } nextToken } }`;
|
|
30
|
+
const HIDDEN_SUBMISSIONS = `query Hidden($nextToken: String) { listReaderSubmissions(filter: { state: { eq: "hidden" } }, limit: 500, nextToken: $nextToken) { items { ${SUBMISSION_FIELDS} } nextToken } }`;
|
|
31
|
+
const HIDDEN_COMMENTS = `query Hidden($nextToken: String) { listReaderComments(filter: { state: { eq: "hidden" } }, limit: 500, nextToken: $nextToken) { items { ${COMMENT_FIELDS} } nextToken } }`;
|
|
32
|
+
const OPEN_REPORTS = `query Reports($nextToken: String) { listReaderReports(filter: { state: { eq: "open" } }, limit: 500, nextToken: $nextToken) { items { id targetType target reporterReaderId reason state createdAt } nextToken } }`;
|
|
33
|
+
const ACTIONS = `query Actions($nextToken: String) { listModerationActions(limit: 500, nextToken: $nextToken) { items { id by actorSub action targetType target reason fromState toState undoOf undoneBy at } nextToken } }`;
|
|
34
|
+
const HANDLES = `query Handles($nextToken: String) { listReaderHandles(limit: 1000, nextToken: $nextToken) { items { id readerId } nextToken } }`;
|
|
35
|
+
const MODERATE = `mutation Moderate($action: String!, $targetType: String!, $target: String!, $reason: String, $undoOf: String) { moderateReader(action: $action, targetType: $targetType, target: $target, reason: $reason, undoOf: $undoOf) { status id error message } }`;
|
|
36
|
+
|
|
37
|
+
type GraphResult = { data?: Record<string, { items: unknown[]; nextToken?: string | null } | Receipt | null>; errors?: { message?: string }[] };
|
|
38
|
+
const MAXIMUM_PAGES = 20;
|
|
39
|
+
|
|
40
|
+
async function loadAll<T>(query: string, field: string): Promise<T[]> {
|
|
41
|
+
const client = generateClient({ authMode: "userPool" });
|
|
42
|
+
const rows: T[] = [];
|
|
43
|
+
let nextToken: string | null | undefined;
|
|
44
|
+
for (let page = 0; page < MAXIMUM_PAGES; page += 1) {
|
|
45
|
+
const result = (await client.graphql({ query, variables: { nextToken } })) as GraphResult;
|
|
46
|
+
if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load moderation data.");
|
|
47
|
+
const connection = result.data?.[field] as { items: T[]; nextToken?: string | null } | undefined;
|
|
48
|
+
rows.push(...(connection?.items ?? []).filter(Boolean));
|
|
49
|
+
nextToken = connection?.nextToken;
|
|
50
|
+
if (!nextToken) break;
|
|
51
|
+
}
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function moderate(variables: Record<string, string | undefined>): Promise<Receipt> {
|
|
56
|
+
const client = generateClient({ authMode: "userPool" });
|
|
57
|
+
const result = (await client.graphql({ query: MODERATE, variables })) as GraphResult;
|
|
58
|
+
if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
|
|
59
|
+
return (result.data?.moderateReader as Receipt | null) ?? { status: "refused", message: "The action did not answer." };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ModerationViewProps = {
|
|
63
|
+
mode: unknown;
|
|
64
|
+
queue: ModerationRow[];
|
|
65
|
+
hidden: ModerationRow[];
|
|
66
|
+
log: ActionEntry[];
|
|
67
|
+
tab: "queue" | "hidden" | "log";
|
|
68
|
+
busy: boolean;
|
|
69
|
+
status: string;
|
|
70
|
+
reason: string;
|
|
71
|
+
onTab: (tab: "queue" | "hidden" | "log") => void;
|
|
72
|
+
onReason: (reason: string) => void;
|
|
73
|
+
onAction: (action: ModerationActionName, targetType: ModerationTarget, target: string, undoOf?: string) => void;
|
|
74
|
+
onRefresh: () => void;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function Row({ row, busy, onAction, listed }: { row: ModerationRow; busy: boolean; onAction: ModerationViewProps["onAction"]; listed: "queue" | "hidden" }) {
|
|
78
|
+
const kind = row.kind === "submission" ? "Submission" : "Comment";
|
|
79
|
+
return (
|
|
80
|
+
<li data-moderation-row={row.id}>
|
|
81
|
+
<article aria-label={`${kind} by ${shortReader(row.readerId, row.handle)}`}>
|
|
82
|
+
<p>
|
|
83
|
+
<strong>{kind}</strong> by {shortReader(row.readerId, row.handle)} <time dateTime={row.createdAt}>{row.createdAt}</time> ({row.state}
|
|
84
|
+
{row.reports.length > 0 ? `, ${row.reports.length} ${row.reports.length === 1 ? "report" : "reports"}` : ""})
|
|
85
|
+
</p>
|
|
86
|
+
{row.kind === "submission" ? (
|
|
87
|
+
<p>
|
|
88
|
+
{row.url ? (
|
|
89
|
+
<a href={row.url} target="_blank" rel="noopener noreferrer nofollow">{row.title}</a>
|
|
90
|
+
) : (
|
|
91
|
+
<span>{row.title}</span>
|
|
92
|
+
)}
|
|
93
|
+
</p>
|
|
94
|
+
) : null}
|
|
95
|
+
{row.text ? <p style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.text}</p> : null}
|
|
96
|
+
{row.signals.length > 0 ? (
|
|
97
|
+
<ul aria-label="Automatic signals">
|
|
98
|
+
{row.signals.map((signal, index) => (
|
|
99
|
+
<li key={`${signal.detector}-${index}`}>Signal ({signal.severity}): {signal.policy} / {signal.detector}{signal.excerpt ? ` — “${signal.excerpt}”` : ""}</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
) : null}
|
|
103
|
+
{row.reports.length > 0 ? (
|
|
104
|
+
<ul aria-label="Reports">
|
|
105
|
+
{row.reports.map((report) => (
|
|
106
|
+
<li key={report.id}>Reported: {report.reason}</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
) : null}
|
|
110
|
+
<p>
|
|
111
|
+
{listed === "queue" ? (
|
|
112
|
+
<button type="button" disabled={busy} onClick={() => onAction("approve", row.kind, row.id)}>Approve</button>
|
|
113
|
+
) : (
|
|
114
|
+
<button type="button" disabled={busy} onClick={() => onAction("unhide", row.kind, row.id)}>Show again</button>
|
|
115
|
+
)}{" "}
|
|
116
|
+
{row.state !== "hidden" ? <button type="button" disabled={busy} onClick={() => onAction("hide", row.kind, row.id)}>Hide</button> : null}{" "}
|
|
117
|
+
{row.canPromote ? <button type="button" disabled={busy} onClick={() => onAction("promote", row.kind, row.id)}>Promote to a draft</button> : null}{" "}
|
|
118
|
+
<button type="button" disabled={busy} onClick={() => onAction("ban", "reader", row.readerId)}>Ban reader</button>{" "}
|
|
119
|
+
<button type="button" disabled={busy} onClick={() => onAction("limit", "reader", row.readerId)}>Limit reader</button>
|
|
120
|
+
</p>
|
|
121
|
+
</article>
|
|
122
|
+
</li>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function ModerationView(props: ModerationViewProps) {
|
|
127
|
+
const { queue, hidden, log, tab, busy } = props;
|
|
128
|
+
return (
|
|
129
|
+
<section aria-labelledby="newsroom-moderation-title" data-news-desk-admin-panel="moderation">
|
|
130
|
+
<h3 id="newsroom-moderation-title">Moderation</h3>
|
|
131
|
+
<p>{moderationModeText(props.mode)} Only editors and admins can see this.</p>
|
|
132
|
+
<p role="tablist" aria-label="Moderation views">
|
|
133
|
+
<button type="button" role="tab" aria-selected={tab === "queue"} onClick={() => props.onTab("queue")}>Queue ({queue.length})</button>{" "}
|
|
134
|
+
<button type="button" role="tab" aria-selected={tab === "hidden"} onClick={() => props.onTab("hidden")}>Hidden ({hidden.length})</button>{" "}
|
|
135
|
+
<button type="button" role="tab" aria-selected={tab === "log"} onClick={() => props.onTab("log")}>Recent actions</button>{" "}
|
|
136
|
+
<button type="button" onClick={props.onRefresh} disabled={busy}>Refresh</button>
|
|
137
|
+
</p>
|
|
138
|
+
<label>
|
|
139
|
+
Reason for the next action (optional){" "}
|
|
140
|
+
<input type="text" value={props.reason} maxLength={500} onChange={(event) => props.onReason(event.target.value)} />
|
|
141
|
+
</label>
|
|
142
|
+
<p role="status" aria-live="polite">{props.status}</p>
|
|
143
|
+
{tab === "queue" ? (
|
|
144
|
+
queue.length === 0 ? <p>Nothing is waiting for a decision.</p> : <ul>{queue.map((row) => <Row key={row.id} row={row} busy={busy} onAction={props.onAction} listed="queue" />)}</ul>
|
|
145
|
+
) : null}
|
|
146
|
+
{tab === "hidden" ? (
|
|
147
|
+
hidden.length === 0 ? <p>Nothing is hidden.</p> : <ul>{hidden.map((row) => <Row key={row.id} row={row} busy={busy} onAction={props.onAction} listed="hidden" />)}</ul>
|
|
148
|
+
) : null}
|
|
149
|
+
{tab === "log" ? (
|
|
150
|
+
log.length === 0 ? <p>No moderation actions yet.</p> : (
|
|
151
|
+
<table>
|
|
152
|
+
<thead>
|
|
153
|
+
<tr><th scope="col">When</th><th scope="col">Action</th><th scope="col">Target</th><th scope="col">By</th><th scope="col">Reason</th><th scope="col"><span>Undo</span></th></tr>
|
|
154
|
+
</thead>
|
|
155
|
+
<tbody>
|
|
156
|
+
{log.map((entry) => (
|
|
157
|
+
<tr key={entry.id}>
|
|
158
|
+
<td>{entry.at}</td>
|
|
159
|
+
<td>{entry.label} ({entry.fromState} to {entry.toState}){entry.undoneBy ? ", undone" : ""}</td>
|
|
160
|
+
<td>{entry.targetType} {entry.target.slice(0, 12)}</td>
|
|
161
|
+
<td>{entry.byLabel}</td>
|
|
162
|
+
<td>{entry.reason}</td>
|
|
163
|
+
<td>
|
|
164
|
+
{entry.undoable ? (
|
|
165
|
+
<button type="button" disabled={busy} onClick={() => props.onAction("undo", entry.targetType as ModerationTarget, entry.target, entry.id)}>Undo</button>
|
|
166
|
+
) : null}
|
|
167
|
+
</td>
|
|
168
|
+
</tr>
|
|
169
|
+
))}
|
|
170
|
+
</tbody>
|
|
171
|
+
</table>
|
|
172
|
+
)
|
|
173
|
+
) : null}
|
|
174
|
+
</section>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function ModerationPanel() {
|
|
179
|
+
const mode = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering")?.moderation, []);
|
|
180
|
+
const [tab, setTab] = useState<"queue" | "hidden" | "log">("queue");
|
|
181
|
+
const [data, setData] = useState<{ queue: ModerationRow[]; hidden: ModerationRow[]; log: ActionEntry[] } | null>(null);
|
|
182
|
+
const [status, setStatus] = useState("Loading...");
|
|
183
|
+
const [busy, setBusy] = useState(false);
|
|
184
|
+
const [reason, setReason] = useState("");
|
|
185
|
+
|
|
186
|
+
const refresh = useCallback(async (quiet = false) => {
|
|
187
|
+
setBusy(true);
|
|
188
|
+
try {
|
|
189
|
+
const [newSubmissions, newComments, hiddenSubmissions, hiddenComments, reports, actions, handleRows] = await Promise.all([
|
|
190
|
+
loadAll<SubmissionRow>(QUEUE_SUBMISSIONS, "listReaderSubmissionsByModerationAndCreatedAt"),
|
|
191
|
+
loadAll<CommentRow>(QUEUE_COMMENTS, "listReaderCommentsByModerationAndCreatedAt"),
|
|
192
|
+
loadAll<SubmissionRow>(HIDDEN_SUBMISSIONS, "listReaderSubmissions"),
|
|
193
|
+
loadAll<CommentRow>(HIDDEN_COMMENTS, "listReaderComments"),
|
|
194
|
+
loadAll<ReportRow>(OPEN_REPORTS, "listReaderReports"),
|
|
195
|
+
loadAll<ActionRow>(ACTIONS, "listModerationActions"),
|
|
196
|
+
loadAll<{ id: string; readerId: string }>(HANDLES, "listReaderHandles"),
|
|
197
|
+
]);
|
|
198
|
+
const handles = Object.fromEntries(handleRows.map((row) => [row.readerId, row.id]));
|
|
199
|
+
setData({
|
|
200
|
+
queue: buildQueue({ submissions: newSubmissions, comments: newComments, reports, handles }),
|
|
201
|
+
hidden: buildHidden({ submissions: hiddenSubmissions, comments: hiddenComments, reports, handles }),
|
|
202
|
+
log: buildActionLog(actions),
|
|
203
|
+
});
|
|
204
|
+
if (!quiet) setStatus("");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
setStatus(`Error: ${error instanceof Error ? error.message : "Could not load moderation data."}`);
|
|
207
|
+
} finally {
|
|
208
|
+
setBusy(false);
|
|
209
|
+
}
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
void refresh();
|
|
214
|
+
}, [refresh]);
|
|
215
|
+
|
|
216
|
+
const onAction = useCallback(
|
|
217
|
+
async (action: ModerationActionName, targetType: ModerationTarget, target: string, undoOf?: string) => {
|
|
218
|
+
setBusy(true);
|
|
219
|
+
setStatus("Working...");
|
|
220
|
+
try {
|
|
221
|
+
const receipt = await moderate({ action, targetType, target, ...(reason.trim() ? { reason: reason.trim() } : {}), ...(undoOf ? { undoOf } : {}) });
|
|
222
|
+
setStatus(receiptMessage(receipt));
|
|
223
|
+
if (receipt.status === "ok") setReason("");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
setStatus(`Error: ${error instanceof Error ? error.message : "The action failed."}`);
|
|
226
|
+
}
|
|
227
|
+
await refresh(true);
|
|
228
|
+
},
|
|
229
|
+
[reason, refresh],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<ModerationView
|
|
234
|
+
mode={mode}
|
|
235
|
+
queue={data?.queue ?? []}
|
|
236
|
+
hidden={data?.hidden ?? []}
|
|
237
|
+
log={data?.log ?? []}
|
|
238
|
+
tab={tab}
|
|
239
|
+
busy={busy}
|
|
240
|
+
status={status}
|
|
241
|
+
reason={reason}
|
|
242
|
+
onTab={setTab}
|
|
243
|
+
onReason={setReason}
|
|
244
|
+
onAction={(action, targetType, target, undoOf) => void onAction(action, targetType, target, undoOf)}
|
|
245
|
+
onRefresh={() => void refresh()}
|
|
246
|
+
/>
|
|
247
|
+
);
|
|
248
|
+
}
|