Create backend for Dashboards

This commit is contained in:
Pico2x 2022-08-25 13:03:29 +01:00
parent 1c73d21925
commit 35aa6b733d
8 changed files with 146 additions and 0 deletions

13
common/dashboard.ts Normal file
View File

@ -0,0 +1,13 @@
import { JSONContent } from '@tiptap/core'
export type Dashboard = {
id: string
name: string
content: JSONContent
creatorId: string // User id
createdTime: number
lastUpdatedTime: number
slug: string
}
export const MAX_DASHBOARD_NAME_LENGTH = 75

View File

@ -169,5 +169,14 @@ service cloud.firestore {
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember(); allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
} }
} }
match /dashboards/{dashboardId} {
allow read;
allow update: if request.auth.uid == resource.data.creatorId
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'content']);
allow delete: if request.auth.uid == resource.data.creatorId;
}
} }
} }

View File

@ -0,0 +1,86 @@
import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { Contract } from '../../common/contract'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { Dashboard, MAX_DASHBOARD_NAME_LENGTH } from '../../common/dashboard'
import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core'
import { z } from 'zod'
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
z.record(z.any()),
z.object({
type: z.string().optional(),
attrs: z.record(z.any()).optional(),
content: z.array(contentSchema).optional(),
marks: z
.array(
z.intersection(
z.record(z.any()),
z.object({
type: z.string(),
attrs: z.record(z.any()).optional(),
})
)
)
.optional(),
text: z.string().optional(),
})
)
)
const dashboardSchema = z.object({
name: z.string().min(1).max(MAX_DASHBOARD_NAME_LENGTH),
content: contentSchema,
})
export const createdashboard = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { name, content } = validate(dashboardSchema, req.body)
const creator = await getUser(auth.uid)
if (!creator)
throw new APIError(400, 'No user exists with the authenticated user ID.')
console.log('creating dashboard for', creator.username, 'named', name)
const slug = await getSlug(name)
const dashboardRef = firestore.collection('dashboards').doc()
const dashboard: Dashboard = {
id: dashboardRef.id,
creatorId: creator.id,
slug,
name,
createdTime: Date.now(),
content: content,
}
await dashboardRef.create(dashboard)
return { status: 'success', dashboard: dashboard }
})
export const getSlug = async (name: string) => {
const proposedSlug = slugify(name)
const preexistingDashboard = await getDashboardFromSlug(proposedSlug)
return preexistingDashboard
? proposedSlug + '-' + randomString()
: proposedSlug
}
export async function getDashboardFromSlug(slug: string) {
const firestore = admin.firestore()
const snap = await firestore
.collection('dashboards')
.where('slug', '==', slug)
.get()
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
}

View File

@ -71,6 +71,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge' import { acceptchallenge } from './accept-challenge'
import { getcustomtoken } from './get-custom-token' import { getcustomtoken } from './get-custom-token'
import { createdashboard } from './create-dashboard'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any) return onRequest(opts, handler as any)
@ -96,6 +97,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser) const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge) const acceptChallenge = toCloudFunction(acceptchallenge)
const getCustomTokenFunction = toCloudFunction(getcustomtoken) const getCustomTokenFunction = toCloudFunction(getcustomtoken)
const createDashboardFunction = toCloudFunction(createdashboard)
export { export {
healthFunction as health, healthFunction as health,
@ -119,4 +121,5 @@ export {
getCurrentUserFunction as getcurrentuser, getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
getCustomTokenFunction as getcustomtoken, getCustomTokenFunction as getcustomtoken,
createDashboardFunction as createdashboard,
} }

View File

@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe' import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { getcustomtoken } from './get-custom-token' import { getcustomtoken } from './get-custom-token'
import { createdashboard } from './create-dashboard'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -67,6 +68,7 @@ addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/getcustomtoken', getcustomtoken)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createdashboard', createdashboard)
app.listen(PORT) app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`) console.log(`Serving functions on port ${PORT}.`)

View File

@ -4,6 +4,7 @@ import { chunk } from 'lodash'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { Dashboard } from 'common/dashboard'
export const log = (...args: unknown[]) => { export const log = (...args: unknown[]) => {
console.log(`[${new Date().toISOString()}]`, ...args) console.log(`[${new Date().toISOString()}]`, ...args)
@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => {
return getDoc<Group>('groups', groupId) return getDoc<Group>('groups', groupId)
} }
export const getDashboard = (dashboardId: string) => {
return getDoc<Dashboard>('dashboards', dashboardId)
}
export const getUser = (userId: string) => { export const getUser = (userId: string) => {
return getDoc<User>('users', userId) return getDoc<User>('users', userId)
} }

View File

@ -88,3 +88,7 @@ export function acceptChallenge(params: any) {
export function getCurrentUser(params: any) { export function getCurrentUser(params: any) {
return call(getFunctionUrl('getcurrentuser'), 'GET', params) return call(getFunctionUrl('getcurrentuser'), 'GET', params)
} }
export function createDashboard(params: any) {
return call(getFunctionUrl('createdashboard'), 'POST', params)
}

View File

@ -0,0 +1,24 @@
import { deleteDoc, doc, updateDoc } from 'firebase/firestore'
import { Dashboard } from 'common/dashboard'
import { coll, getValue } from './utils'
export const dashboards = coll<Dashboard>('dashboards')
export function dashboardPath(dashboardSlug: string) {
return `/dashboard/${dashboardSlug}}`
}
export function updateDashboard(
dashboard: Dashboard,
updates: Partial<Dashboard>
) {
return updateDoc(doc(dashboards, dashboard.id), updates)
}
export function deleteDashboard(dashboard: Dashboard) {
return deleteDoc(doc(dashboards, dashboard.id))
}
export function getDashboard(dashboardId: string) {
return getValue<Dashboard>(doc(dashboards, dashboardId))
}