diff --git a/common/dashboard.ts b/common/dashboard.ts new file mode 100644 index 00000000..8f7746fa --- /dev/null +++ b/common/dashboard.ts @@ -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 diff --git a/firestore.rules b/firestore.rules index b28ac6a5..23bf19d1 100644 --- a/firestore.rules +++ b/firestore.rules @@ -169,5 +169,14 @@ service cloud.firestore { 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; + } } } diff --git a/functions/src/create-dashboard.ts b/functions/src/create-dashboard.ts new file mode 100644 index 00000000..229406c6 --- /dev/null +++ b/functions/src/create-dashboard.ts @@ -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 = 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) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 26a1ddf6..123d3064 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -71,6 +71,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' import { getcustomtoken } from './get-custom-token' +import { createdashboard } from './create-dashboard' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -96,6 +97,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const getCustomTokenFunction = toCloudFunction(getcustomtoken) +const createDashboardFunction = toCloudFunction(createdashboard) export { healthFunction as health, @@ -119,4 +121,5 @@ export { getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, getCustomTokenFunction as getcustomtoken, + createDashboardFunction as createdashboard, } diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 8d848f7f..b78d6307 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { getcustomtoken } from './get-custom-token' +import { createdashboard } from './create-dashboard' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -67,6 +68,7 @@ addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) +addEndpointRoute('/createdashboard', createdashboard) app.listen(PORT) console.log(`Serving functions on port ${PORT}.`) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 2d620728..34e56164 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -4,6 +4,7 @@ import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' import { Group } from '../../common/group' +import { Dashboard } from 'common/dashboard' export const log = (...args: unknown[]) => { console.log(`[${new Date().toISOString()}]`, ...args) @@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => { return getDoc('groups', groupId) } +export const getDashboard = (dashboardId: string) => { + return getDoc('dashboards', dashboardId) +} + export const getUser = (userId: string) => { return getDoc('users', userId) } diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 5f250ce7..4e352fa9 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -88,3 +88,7 @@ export function acceptChallenge(params: any) { export function getCurrentUser(params: any) { return call(getFunctionUrl('getcurrentuser'), 'GET', params) } + +export function createDashboard(params: any) { + return call(getFunctionUrl('createdashboard'), 'POST', params) +} diff --git a/web/lib/firebase/dashboards.ts b/web/lib/firebase/dashboards.ts new file mode 100644 index 00000000..8bcdf0da --- /dev/null +++ b/web/lib/firebase/dashboards.ts @@ -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('dashboards') + +export function dashboardPath(dashboardSlug: string) { + return `/dashboard/${dashboardSlug}}` +} + +export function updateDashboard( + dashboard: Dashboard, + updates: Partial +) { + 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(doc(dashboards, dashboardId)) +}