Skip to content

Commit 485a304

Browse files
e-schneidjsdevel
andauthored
feat: add support for user requests (#1959)
* feat: add support for user requests * add slack notifications for user requests * Fixing JSDoc missing Co-authored-by: trigramdev9 <jsdevel@trigram.co>
1 parent 715b161 commit 485a304

14 files changed

Lines changed: 471 additions & 28 deletions

File tree

.env.tpl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ SENTRY_DSN=https://000000@0000000.ingest.sentry.io/00000
2222
SENTRY_TOKEN=secret
2323
SENTRY_UPLOAD=false
2424

25+
## User request Slack notification webhook URL
26+
SLACK_USER_REQUEST_WEBHOOK_URL=
27+
2528
## API PostgREST
2629
DATABASE_URL=http://localhost:3000
2730
DATABASE_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTYwMzk2ODgzNCwiZXhwIjoyNTUwNjUzNjM0LCJyb2xlIjoic2VydmljZV9yb2xlIn0.necIJaiP7X2T2QjGeV-FhpkizcNTX8HjDDBAxpgQTEI

packages/api/src/bindings.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ export interface ServiceConfiguration {
7575

7676
/** Mailchimp api key */
7777
MAILCHIMP_API_KEY: string
78+
79+
/** Slack webhook url */
80+
SLACK_USER_REQUEST_WEBHOOK_URL: string
7881
}
7982

8083
export interface Ucan {
@@ -226,6 +229,13 @@ export interface PinsResponse {
226229
delegates: string[]
227230
}
228231

232+
export interface RequestFormItem {
233+
label: string
234+
value: string
235+
}
236+
237+
export type RequestForm = Array<RequestFormItem>
238+
229239
/**
230240
* The known structural completeness of a given DAG. "Unknown" structure means
231241
* it could be a partial or it could be a complete DAG i.e. we haven't walked

packages/api/src/config.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export function serviceConfigFromVariables(vars) {
9999
S3_ACCESS_KEY_ID: vars.S3_ACCESS_KEY_ID,
100100
S3_SECRET_ACCESS_KEY: vars.S3_SECRET_ACCESS_KEY,
101101
S3_BUCKET_NAME: vars.S3_BUCKET_NAME,
102+
SLACK_USER_REQUEST_WEBHOOK_URL: vars.SLACK_USER_REQUEST_WEBHOOK_URL,
102103
PRIVATE_KEY: vars.PRIVATE_KEY,
103104
// These are injected in esbuild
104105
// @ts-ignore
@@ -157,7 +158,12 @@ export function loadConfigVariables() {
157158
}
158159
}
159160

160-
const optional = ['CLUSTER_SERVICE', 'CLUSTER_API_URL', 'S3_ENDPOINT']
161+
const optional = [
162+
'CLUSTER_SERVICE',
163+
'CLUSTER_API_URL',
164+
'S3_ENDPOINT',
165+
'SLACK_USER_REQUEST_WEBHOOK_URL',
166+
]
161167

162168
for (const name of optional) {
163169
const val = globals[name]

packages/api/src/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { metaplexUpload } from './routes/metaplex-upload.js'
2323
import { blogSubscribe } from './routes/blog-subscribe.js'
2424
import { userDIDRegister } from './routes/user-did-register.js'
2525
import { userTags } from './routes/user-tags.js'
26+
import { userRequestsAdd, userRequestsGet } from './routes/user-requests.js'
2627
import { ucanToken } from './routes/ucan-token.js'
2728
import { did } from './routes/did.js'
2829
import { getServiceConfig } from './config.js'
@@ -164,6 +165,13 @@ r.add(
164165
)
165166
r.add('get', '/user/tags', withAuth(withMode(userTags, RO)), [postCors])
166167

168+
r.add('post', '/user/request', withAuth(withMode(userRequestsAdd, RW)), [
169+
postCors,
170+
])
171+
r.add('get', '/user/request', withAuth(withMode(userRequestsGet, RW)), [
172+
postCors,
173+
])
174+
167175
// Tokens
168176
r.add('get', '/internal/tokens', withAuth(withMode(tokensList, RO)), [postCors])
169177
r.add(
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { checkAuth } from '../utils/auth.js'
2+
import { JSONResponse } from '../utils/json-response.js'
3+
import { getPendingProposals, getTagValue, hasTag } from '../utils/utils.js'
4+
import { getServiceConfig } from '../config.js'
5+
import { DBClient } from '../utils/db-client.js'
6+
7+
/** @type {import('../bindings').Handler} */
8+
export const userRequestsAdd = async (event, ctx) => {
9+
const { user } = checkAuth(ctx)
10+
11+
const userId = user.id
12+
const { tagName, requestedTagValue, userProposalForm } =
13+
await event.request.json()
14+
const res = await ctx.db.createUserRequest(
15+
userId,
16+
tagName,
17+
requestedTagValue,
18+
userProposalForm
19+
)
20+
21+
try {
22+
notifySlack(userId, tagName, requestedTagValue, userProposalForm, ctx.db)
23+
} catch (e) {
24+
console.error('Failed to notify Slack: ', e)
25+
}
26+
27+
return new JSONResponse({ ok: true, value: res })
28+
}
29+
30+
/** @type {import('../bindings').Handler} */
31+
export const userRequestsGet = async (event, ctx) => {
32+
const { user } = checkAuth(ctx)
33+
34+
const userId = user.id
35+
const res = await ctx.db.getUserRequests(userId)
36+
37+
return new JSONResponse({ ok: true, value: getPendingProposals(res) })
38+
}
39+
40+
/**
41+
*
42+
* @param {number} userId
43+
* @param {string} userProposalForm
44+
* @param {string} tagName
45+
* @param {string} requestedTagValue
46+
* @param {DBClient} db
47+
*/
48+
const notifySlack = async (
49+
userId,
50+
tagName,
51+
requestedTagValue,
52+
userProposalForm,
53+
db
54+
) => {
55+
const webhookUrl = getServiceConfig().SLACK_USER_REQUEST_WEBHOOK_URL
56+
57+
if (!webhookUrl) {
58+
return
59+
}
60+
61+
/** @type {import('../bindings').RequestForm} */
62+
let form
63+
try {
64+
form = JSON.parse(userProposalForm)
65+
} catch (e) {
66+
console.error('Failed to parse user request form: ', e)
67+
return
68+
}
69+
70+
const user = await db.getUserById(userId)
71+
72+
fetch(webhookUrl, {
73+
method: 'POST',
74+
headers: {
75+
'Content-type': 'application/json',
76+
},
77+
body: JSON.stringify({
78+
text: `
79+
>*Username*
80+
>${user.name}
81+
>
82+
>*Email*
83+
>${user.email}
84+
>
85+
>*User Id*
86+
>${userId}
87+
>
88+
>*Requested Tag Name*
89+
>${tagName}
90+
>
91+
>*Requested Tag Value*
92+
>${requestedTagValue}
93+
>${form
94+
.map(
95+
({ label, value }) => `
96+
>*${label}*
97+
>${value}
98+
>`
99+
)
100+
.join('')}
101+
`,
102+
}),
103+
})
104+
}

packages/api/src/utils/db-client.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,26 @@ export class DBClient {
7575
}
7676
}
7777

78+
/**
79+
* Gets a user by user id
80+
*
81+
* @param {number} id
82+
* @returns
83+
*/
84+
async getUserById(id) {
85+
const { data, error, status } = await this.client
86+
.from('user')
87+
.select('*')
88+
.eq('id', id)
89+
.single()
90+
91+
if (error) {
92+
throw new DBError(error)
93+
}
94+
95+
return data
96+
}
97+
7898
/**
7999
* Get user by magic.link or old github id
80100
*
@@ -130,6 +150,75 @@ export class DBClient {
130150
return data?.status === 'Blocked'
131151
}
132152

153+
/**
154+
* Gets user tag change requests
155+
*
156+
* @param {number} userId
157+
*/
158+
async getUserRequests(userId) {
159+
const { data, error } = await this.client
160+
.from('user_tag_proposal')
161+
.select('*')
162+
.match({ user_id: userId })
163+
.is('deleted_at', null)
164+
165+
if (error) {
166+
throw new DBError(error)
167+
}
168+
169+
return data
170+
}
171+
172+
/**
173+
* Creates a user tag change request
174+
*
175+
* @param {number} userId
176+
* @param {string} tagName
177+
* @param {string} requestedTagValue
178+
* @param {JSON} userProposalForm
179+
* @returns
180+
*/
181+
async createUserRequest(
182+
userId,
183+
tagName,
184+
requestedTagValue,
185+
userProposalForm
186+
) {
187+
const { data: deleteData, status: deleteStatus } = await this.client
188+
.from('user_tag_proposal')
189+
.update({
190+
deleted_at: new Date().toISOString(),
191+
})
192+
.match({ user_id: userId, tag: tagName })
193+
.is('deleted_at', null)
194+
195+
if (
196+
deleteStatus === 200 ||
197+
((deleteStatus === 406 || deleteStatus === 404) && !deleteData)
198+
) {
199+
const { error: insertError, status: insertStatus } = await this.client
200+
.from('user_tag_proposal')
201+
.insert({
202+
user_id: userId,
203+
tag: tagName,
204+
proposed_tag_value: requestedTagValue,
205+
inserted_at: new Date().toISOString(),
206+
user_proposal_form: userProposalForm,
207+
})
208+
.single()
209+
210+
if (insertError) {
211+
throw new DBError(insertError)
212+
}
213+
214+
if (insertStatus === 201) {
215+
return true
216+
}
217+
}
218+
219+
return false
220+
}
221+
133222
/**
134223
* Create upload with content and pins
135224
*

packages/api/src/utils/utils.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,20 @@ export function hasTag(user, tagName, value) {
9797
)
9898
)
9999
}
100+
101+
/**
102+
* @param {Array<object | any> | null} proposals
103+
* @return {object}
104+
*/
105+
export function getPendingProposals(proposals) {
106+
if (Array.isArray(proposals)) {
107+
return proposals.reduce((acc, p) => {
108+
if (p['tag']) {
109+
acc[p['tag']] = !p['admin_decision_type']
110+
}
111+
return acc
112+
}, {})
113+
}
114+
115+
return {}
116+
}

packages/api/test/scripts/globals.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ globalThis.METAPLEX_AUTH_TOKEN = 'metaplex-test-token'
1111
globalThis.LOGTAIL_TOKEN = ''
1212
globalThis.PRIVATE_KEY = 'xmbtWjE9eYuAxae9G65lQSkw36HV6H+0LSFq2aKqVwY='
1313
globalThis.SENTRY_DSN = 'https://test@test.ingest.sentry.io/0000000'
14+
globalThis.SLACK_USER_REQUEST_WEBHOOK_URL = 'test'
1415

1516
globalThis.CLUSTER_API_URL = 'http://localhost:9094'
1617
// will be used with we can active auth in cluster base64 of test:test

packages/website/components/types.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export interface Tag {
2121
selected?: boolean
2222
}
2323

24+
interface RequestFormItem {
25+
label: string
26+
value: string
27+
}
28+
29+
export type RequestForm = Array<RequestFormItem>
2430
export interface Tags {
2531
tags: Tag[] | string[]
2632
className?: string
@@ -57,8 +63,13 @@ export interface UserTag {
5763
HasPsaAccess?: boolean
5864
}
5965

66+
export interface TagProposal {
67+
HasPsaAccess?: boolean
68+
}
69+
6070
export interface User extends MagicUser {
6171
tags: UserTag
72+
pendingTagProposals: TagProposal
6273
}
6374

6475
export interface LayoutChildrenProps {

0 commit comments

Comments
 (0)