diff --git a/apps/backend/package.json b/apps/backend/package.json index 60421939..de2bcccc 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -17,6 +17,7 @@ "express": "^4.18.2", "morgan": "^1.10.0", "prisma": "^5.4.2", + "random-animal-name": "^0.1.1", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/apps/backend/prisma/migrations/20231012082544_external_id_optional/migration.sql b/apps/backend/prisma/migrations/20231012082544_external_id_optional/migration.sql new file mode 100644 index 00000000..98890914 --- /dev/null +++ b/apps/backend/prisma/migrations/20231012082544_external_id_optional/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "profiles_project_id_external_id_key"; + +-- AlterTable +ALTER TABLE "profiles" ALTER COLUMN "external_id" DROP NOT NULL; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0365e482..d40ef1e7 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -69,7 +69,7 @@ model Event { model Profile { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - external_id String + external_id String? first_name String? last_name String? email String? @@ -82,7 +82,6 @@ model Profile { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - @@unique([project_id, external_id]) @@map("profiles") } diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 8c6b5304..88c27d64 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,9 +1,10 @@ -import express from 'express' +import express, { ErrorRequestHandler } from 'express' import events from './routes/events' import profiles from './routes/profiles' import { authMiddleware } from './middlewares/auth' import morgan from 'morgan' import { setup } from './routes/setup' +import { errorHandler } from './middlewares/errors' const app = express() const port = process.env.PORT || 8080 @@ -21,6 +22,9 @@ if (process.env.SETUP) { app.use(authMiddleware) app.use('/api/sdk', events) app.use('/api/sdk', profiles) + +app.use(errorHandler) + app.listen(port, () => { console.log(`Listening on port ${port}...`) }) diff --git a/apps/backend/src/middlewares/auth.ts b/apps/backend/src/middlewares/auth.ts index d4d239af..1bf182d2 100644 --- a/apps/backend/src/middlewares/auth.ts +++ b/apps/backend/src/middlewares/auth.ts @@ -1,14 +1,12 @@ import { NextFunction, Request, Response } from "express" import { db } from "../db" +import { createError } from "../responses/errors" export async function authMiddleware(req: Request, res: Response, next: NextFunction) { const secret = req.headers['mixan-client-secret'] as string | undefined if(!secret) { - return res.status(401).json({ - code: 'UNAUTHORIZED', - message: 'Missing client secret', - }) + return next(createError(401, 'Misisng client secret')) } const client = await db.client.findFirst({ @@ -18,10 +16,7 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc }) if(!client) { - return res.status(401).json({ - code: 'UNAUTHORIZED', - message: 'Invalid client secret', - }) + return next(createError(401, 'Invalid client secret')) } req.client = { diff --git a/apps/backend/src/middlewares/errors.ts b/apps/backend/src/middlewares/errors.ts new file mode 100644 index 00000000..48d89cad --- /dev/null +++ b/apps/backend/src/middlewares/errors.ts @@ -0,0 +1,16 @@ +import { NextFunction, Request, Response } from "express"; +import { HttpError } from "../responses/errors"; +import { MixanErrorResponse } from "@mixan/types"; + +export const errorHandler = (error: HttpError | Error, req: Request, res: Response, next: NextFunction) => { + if(error instanceof HttpError) { + return res.status(error.status).json(error.toJson()) + } + + return res.status(500).json({ + code: 500, + status: 'error', + message: error.message || 'Unexpected error occured', + issues: [] + } satisfies MixanErrorResponse); +}; \ No newline at end of file diff --git a/apps/backend/src/responses/errors.ts b/apps/backend/src/responses/errors.ts index 1da9b6f7..1c388b76 100644 --- a/apps/backend/src/responses/errors.ts +++ b/apps/backend/src/responses/errors.ts @@ -1,40 +1,38 @@ import { MixanIssue, - MixanErrorResponse, - MixanIssuesResponse, + MixanErrorResponse } from '@mixan/types' -export function issues(arr: Array): MixanIssuesResponse { - return { - issues: arr.map((item) => { - return { - field: item.field, - message: item.message, - value: item.value, - } - }), +export class HttpError extends Error { + public status: number + public message: string + public issues: MixanIssue[] + + constructor(status: number, message: string | Error, issues?: MixanIssue[]) { + super(message instanceof Error ? message.message : message) + this.status = status + this.message = message instanceof Error ? message.message : message + this.issues = issues || [] + } + + toJson(): MixanErrorResponse { + return { + code: this.status, + status: 'error', + message: this.message, + issues: this.issues, + } } } -export function makeError(error: unknown): MixanErrorResponse { - if (error instanceof Error) { - return { - code: 'Error', - message: error.message, - } - } - - // @ts-ignore - if ('message' in error) { - return { - code: 'UnknownError', - // @ts-ignore - message: error.message, - } - } - - return { - code: 'UnknownError', - message: 'Unknown error', - } +export function createIssues(arr: Array) { + throw new HttpError(400, 'Issues', arr) +} + +export function createError(status = 500, error: unknown | Error | string) { + if(error instanceof Error || typeof error === 'string') { + return new HttpError(status, error) + } + + return new HttpError(500, 'Unexpected error occured') } diff --git a/apps/backend/src/routes/events.ts b/apps/backend/src/routes/events.ts index 3e010740..79f2a044 100644 --- a/apps/backend/src/routes/events.ts +++ b/apps/backend/src/routes/events.ts @@ -1,39 +1,41 @@ -import {Router} from 'express' +import {NextFunction, Response, Router} from 'express' import { db } from '../db'; import { MixanRequest } from '../types/express'; import { EventPayload } from '@mixan/types'; -import { getEvents, getProfileIdFromEvents } from '../services/event'; +import { getEvents } from '../services/event'; import { success } from '../responses/success'; -import { makeError } from '../responses/errors'; const router = Router(); type PostRequest = MixanRequest> -router.get('/events', async (req, res) => { +router.get('/events', async (req, res, next) => { try { const events = await getEvents(req.client.project_id) res.json(success(events)) } catch (error) { - res.json(makeError(error)) + next(error) } }) -router.post('/events', async (req: PostRequest, res) => { - const projectId = req.client.project_id - const profileId = await getProfileIdFromEvents(projectId, req.body) +router.post('/events', async (req: PostRequest, res: Response, next: NextFunction) => { + try { + const projectId = req.client.project_id + + await db.event.createMany({ + data: req.body.map((event) => ({ + name: event.name, + properties: event.properties, + createdAt: event.time, + project_id: projectId, + profile_id: event.profileId, + })) + }) - await db.event.createMany({ - data: req.body.map(event => ({ - name: event.name, - properties: event.properties, - createdAt: event.time, - project_id: projectId, - profile_id: profileId, - })) - }) - - res.status(201).json(success()) + res.status(201).json(success()) + } catch (error) { + next(error) + } }) export default router \ No newline at end of file diff --git a/apps/backend/src/routes/profiles.ts b/apps/backend/src/routes/profiles.ts index 2dafdc36..ecba0352 100644 --- a/apps/backend/src/routes/profiles.ts +++ b/apps/backend/src/routes/profiles.ts @@ -1,132 +1,131 @@ -import { Router } from 'express' +import { NextFunction, Response, Router } from 'express' import { db } from '../db' import { MixanRequest } from '../types/express' -import { - createProfile, - getProfileByExternalId, - updateProfile, -} from '../services/profile' +import { getProfile, tickProfileProperty } from '../services/profile' import { ProfileDecrementPayload, ProfileIncrementPayload, ProfilePayload, } from '@mixan/types' -import { issues } from '../responses/errors' import { success } from '../responses/success' +import randomAnimalName from 'random-animal-name' const router = Router() type PostRequest = MixanRequest -router.get('/profiles', async (req, res) => { - res.json(success(await db.profile.findMany({ - where: { - project_id: req.client.project_id, - }, - }))) -}) - -router.post('/profiles', async (req: PostRequest, res) => { - const body = req.body - const projectId = req.client.project_id - const profile = await getProfileByExternalId(projectId, body.id) - if (profile) { - await updateProfile(projectId, body.id, body, profile) - } else { - await createProfile(projectId, body) +router.get('/profiles', async (req, res, next) => { + try { + res.json( + success( + await db.profile.findMany({ + where: { + project_id: req.client.project_id, + }, + }) + ) + ) + } catch (error) { + next(error) } - - res.status(profile ? 200 : 201).json(success()) }) router.post( - '/profiles/increment', - async (req: MixanRequest, res) => { - const body = req.body - const projectId = req.client.project_id - const profile = await getProfileByExternalId(projectId, body.id) - - if (profile) { - const existingProperties = ( - typeof profile.properties === 'object' ? profile.properties || {} : {} - ) as Record - const value = - body.name in existingProperties ? existingProperties[body.name] : 0 - const properties = { - ...existingProperties, - [body.name]: value + body.value, - } - - if (typeof value !== 'number') { - return res.status(400).json( - issues([ - { - field: 'name', - message: 'Property is not a number', - value, - }, - ]) - ) - } - - await db.profile.updateMany({ - where: { - external_id: String(body.id), - project_id: req.client.project_id, - }, + '/profiles', + async ( + req: MixanRequest<{ + id: string + properties?: Record + }>, + res: Response, + next: NextFunction + ) => { + try { + const projectId = req.client.project_id + const { id, properties } = req.body + const profile = await db.profile.create({ data: { - properties, + id, + external_id: null, + email: null, + first_name: randomAnimalName(), + last_name: null, + avatar: null, + properties: { + ...(properties || {}), + }, + project_id: projectId, }, }) - } - res.status(200).json(success()) + res.status(201).json(success(profile)) + } catch (error) { + next(error) + } } ) -router.post( - '/profiles/decrement', - async (req: MixanRequest, res) => { - const body = req.body - const projectId = req.client.project_id - const profile = await getProfileByExternalId(projectId, body.id) - +router.put('/profiles/:id', async (req: PostRequest, res: Response, next: NextFunction) => { + try { + const profileId = req.params.id + const profile = await getProfile(profileId) + const { body } = req if (profile) { - const existingProperties = ( - typeof profile.properties === 'object' ? profile.properties || {} : {} - ) as Record - const value = - body.name in existingProperties ? existingProperties[body.name] : 0 - - if (typeof value !== 'number') { - return res.status(400).json( - issues([ - { - field: 'name', - message: 'Property is not a number', - value, - }, - ]) - ) - } - - const properties = { - ...existingProperties, - [body.name]: value - body.value, - } - - await db.profile.updateMany({ + await db.profile.update({ where: { - external_id: String(body.id), - project_id: req.client.project_id, + id: profileId, }, data: { - properties, + external_id: body.id, + email: body.email, + first_name: body.first_name, + last_name: body.last_name, + avatar: body.avatar, + properties: { + ...(typeof profile.properties === 'object' + ? profile.properties || {} + : {}), + ...(body.properties || {}), + }, }, }) - } - res.status(200).json(success()) + res.status(200).json(success()) + } + } catch (error) { + next(error) + } +}) + +router.put( + '/profiles/:id/increment', + async (req: MixanRequest, res: Response, next: NextFunction) => { + try { + await tickProfileProperty({ + name: req.body.name, + tick: req.body.value, + profileId: req.params.id, + }) + res.status(200).json(success()) + } catch (error) { + next(error) + } + } +) + +router.put( + '/profiles/:id/decrement', + async (req: MixanRequest, res: Response, next: NextFunction) => { + try { + await tickProfileProperty({ + name: req.body.name, + tick: -Math.abs(req.body.value), + profileId: req.params.id, + }) + res.status(200).json(success()) + } catch (error) { + next(error) + } } ) diff --git a/apps/backend/src/routes/setup.ts b/apps/backend/src/routes/setup.ts index c65c5d71..8e9527bb 100644 --- a/apps/backend/src/routes/setup.ts +++ b/apps/backend/src/routes/setup.ts @@ -1,9 +1,8 @@ -import { Request, Response } from 'express' +import { NextFunction, Request, Response } from 'express' import { db } from '../db' -import { makeError } from '../responses/errors' import { v4 as uuid } from 'uuid' -export async function setup(req: Request, res: Response) { +export async function setup(req: Request, res: Response, next: NextFunction) { try { const organization = await db.organization.create({ data: { @@ -22,7 +21,7 @@ export async function setup(req: Request, res: Response) { data: { name: 'Acme Website Client', project_id: project.id, - secret: uuid(), + secret: '4bfc4a0b-37e0-4916-b634-95c6a32a2e77', }, }) @@ -32,6 +31,6 @@ export async function setup(req: Request, res: Response) { client, }) } catch (error) { - res.json(makeError(error)) + next(error) } } diff --git a/apps/backend/src/services/event.ts b/apps/backend/src/services/event.ts index 3008e44f..537ac410 100644 --- a/apps/backend/src/services/event.ts +++ b/apps/backend/src/services/event.ts @@ -1,4 +1,3 @@ -import { EventPayload } from "@mixan/types"; import { db } from "../db"; export function getEvents(projectId: string) { @@ -8,22 +7,3 @@ export function getEvents(projectId: string) { } }) } - -export async function getProfileIdFromEvents(projectId: string, events: EventPayload[]) { - const event = events.find(item => !!item.externalId) - if(event?.externalId) { - return db.profile.findUnique({ - where: { - project_id_external_id: { - project_id: projectId, - external_id: event.externalId, - } - } - }).then((res) => { - return res?.id || null - }).catch(() => { - return null - }) - } - return null -} diff --git a/apps/backend/src/services/password.ts b/apps/backend/src/services/password.ts deleted file mode 100644 index a084a350..00000000 --- a/apps/backend/src/services/password.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function hashPassword(password: string) { - return await Bun.password.hash(password); -} - -export async function verifyPassword(password: string,hashedPassword: string) { - return await Bun.password.verify(password, hashedPassword); -} \ No newline at end of file diff --git a/apps/backend/src/services/profile.ts b/apps/backend/src/services/profile.ts index 363d7854..776afe6d 100644 --- a/apps/backend/src/services/profile.ts +++ b/apps/backend/src/services/profile.ts @@ -1,32 +1,15 @@ -import { EventPayload, ProfilePayload } from "@mixan/types"; -import { db } from "../db"; -import { Prisma } from "@prisma/client"; +import { EventPayload, ProfilePayload } from '@mixan/types' +import { db } from '../db' +import { Prisma } from '@prisma/client' +import { HttpError } from '../responses/errors' -export function createProfile(projectId: string, payload: ProfilePayload) { - const { id, email, first_name, last_name, avatar, properties } = payload - return db.profile.create({ - data:{ - external_id: id, - email, - first_name, - last_name, - avatar, - properties: properties || {}, - project_id: projectId, - } - }) -} +type DbProfile = Exclude, null> -type DbProfile = Exclude, null> - -export function getProfileByExternalId(projectId: string, externalId: string) { - return db.profile.findUnique({ +export function getProfile(id: string) { + return db.profile.findUniqueOrThrow({ where: { - project_id_external_id: { - project_id: projectId, - external_id: externalId, - } - } + id, + }, }) } @@ -34,42 +17,47 @@ export function getProfiles(projectId: string) { return db.profile.findMany({ where: { project_id: projectId, - } + }, }) } -export async function updateProfile(projectId: string, profileId: string, payload: Omit, oldProfile: DbProfile) { - const { email, first_name, last_name, avatar, properties } = payload - return db.profile.update({ +export async function tickProfileProperty({ + profileId, + tick, + name, +}: { + profileId: string + tick: number + name: string +}) { + const profile = await getProfile(profileId) + + if (!profile) { + throw new HttpError(404, `Profile not found ${profileId}`) + } + + const properties = ( + typeof profile.properties === 'object' ? profile.properties || {} : {} + ) as Record + const value = name in properties ? properties[name] : 0 + + if (typeof value !== 'number') { + throw new HttpError(400, `Property "${name}" on user is of type ${typeof value}`) + } + + if (typeof tick !== 'number') { + throw new HttpError(400, `Value is not a number ${tick} (${typeof tick})`) + } + + await db.profile.update({ where: { - project_id_external_id: { - project_id: projectId, - external_id: profileId, - } + id: profileId, }, data: { - email, - first_name, - last_name, - avatar, properties: { - ...(typeof oldProfile.properties === 'object' ? oldProfile.properties || {} : {}), - ...(properties || {}), + ...properties, + [name]: value + tick, }, }, }) } - -export async function getInternalProfileId(profileId?: string | null) { - if(!profileId) { - return null - } - - const profile = await db.profile.findFirst({ - where: { - external_id: profileId, - } - }) - - return profile?.id || null -} \ No newline at end of file diff --git a/apps/backend/src/types/express.ts b/apps/backend/src/types/express.ts index f849b01e..7331a4bb 100644 --- a/apps/backend/src/types/express.ts +++ b/apps/backend/src/types/express.ts @@ -1,3 +1,5 @@ -export type MixanRequest = Omit & { +import { Request } from "express" + +export type MixanRequest = Omit & { body: Body } \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 898ec7ef..73621efd 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts index 352f089e..86d9f714 100644 --- a/packages/sdk/index.ts +++ b/packages/sdk/index.ts @@ -1,7 +1,7 @@ +import { v4 as uuid } from 'uuid' import { EventPayload, MixanErrorResponse, - MixanIssuesResponse, MixanResponse, ProfilePayload, } from '@mixan/types' @@ -12,6 +12,9 @@ type MixanOptions = { batchInterval?: number maxBatchSize?: number verbose?: boolean + saveProfileId: (profileId: string) => void, + getProfileId: () => string | null, + removeProfileId: () => void, } class Fetcher { @@ -25,7 +28,11 @@ class Fetcher { this.logger = options.verbose ? console.log : () => {} } - post(path: string, data: Record) { + post( + path: string, + data: Record, + options: FetchRequestInit = {} + ) { const url = `${this.url}${path}` this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2)) return fetch(url, { @@ -35,30 +42,19 @@ class Fetcher { }, method: 'POST', body: JSON.stringify(data), + ...options, }) .then(async (res) => { const response = await res.json< - MixanIssuesResponse | MixanErrorResponse | MixanResponse + MixanErrorResponse | MixanResponse >() - if ('status' in response && response.status === 'ok') { - return response - } - if ('code' in response) { - this.logger(`Mixan error: [${response.code}] ${response.message}`) + if('status' in response && response.status === 'error') { + this.logger(`Mixan request failed: ${url}`, JSON.stringify(response, null, 2)) return null } - - if ('issues' in response) { - this.logger(`Mixan issues:`) - response.issues.forEach((issue) => { - this.logger(` - ${issue.message} (${issue.value})`) - }) - - return null - } - - return null + + return response }) .catch(() => { return null @@ -99,7 +95,7 @@ class Batcher { return } - if (this.queue.length > this.maxBatchSize) { + if (this.queue.length >= this.maxBatchSize) { this.send() return } @@ -116,16 +112,21 @@ class Batcher { export class Mixan { private fetch: Fetcher private eventBatcher: Batcher - private profile: ProfilePayload | null = null - + private profileId?: string + private options: MixanOptions + private logger: (...args: any[]) => void + constructor(options: MixanOptions) { + this.logger = options.verbose ? console.log : () => {} + this.options = options this.fetch = new Fetcher(options) + this.setAnonymousUser() this.eventBatcher = new Batcher(options, (queue) => { this.fetch.post( '/events', queue.map((item) => ({ ...item, - externalId: item.externalId || this.profile?.id, + profileId: item.profileId || this.profileId || null, })) ) }) @@ -140,18 +141,34 @@ export class Mixan { name, properties, time: this.timestamp(), - externalId: this.profile?.id || null, + profileId: this.profileId || null, }) } + private setAnonymousUser() { + const profileId = this.options.getProfileId() + if(profileId) { + this.profileId = profileId + this.logger('Use existing ID', this.profileId); + } else { + this.profileId = uuid() + this.logger('Create new ID', this.profileId); + this.options.saveProfileId(this.profileId) + this.fetch.post('/profiles', { + id: this.profileId, + properties: {}, + }) + } + } + async setUser(profile: ProfilePayload) { - this.profile = profile - await this.fetch.post('/profiles', profile) + await this.fetch.post(`/profiles/${this.profileId}`, profile, { + method: 'PUT' + }) } async setUserProperty(name: string, value: any) { - await this.fetch.post('/profiles', { - ...this.profile, + await this.fetch.post(`/profiles/${this.profileId}`, { properties: { [name]: value, }, @@ -159,33 +176,41 @@ export class Mixan { } async increment(name: string, value: number = 1) { - if (!this.profile) { + if (!this.profileId) { return } - await this.fetch.post('/profiles/increment', { - id: this.profile.id, + await this.fetch.post(`/profiles/${this.profileId}/increment`, { name, value, + }, { + method: 'PUT' }) } async decrement(name: string, value: number = 1) { - if (!this.profile) { + if (!this.profileId) { return } - await this.fetch.post('/profiles/decrement', { - id: this.profile.id, + await this.fetch.post(`/profiles/${this.profileId}/decrement`, { name, value, + }, { + method: 'PUT' }) } - screenView(route: string, properties?: Record) { - this.event('screen_view', { + async screenView(route: string, properties?: Record) { + await this.event('screen_view', { ...(properties || {}), route, }) } + + clear() { + this.eventBatcher.flush() + this.options.removeProfileId() + this.profileId = undefined + } } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 58c586dd..9797592a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -4,9 +4,11 @@ "type": "module", "module": "index.ts", "dependencies": { - "@mixan/types": "workspace:*" + "@mixan/types": "workspace:*", + "uuid": "^9.0.1" }, "devDependencies": { + "@types/uuid": "^9.0.5", "bun-types": "latest", "typescript": "^5.0.0" } diff --git a/packages/types/index.ts b/packages/types/index.ts index 00f756e1..43fb84f2 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -3,7 +3,7 @@ export type MixanJson = Record export type EventPayload = { name: string time: string - externalId: string | null + profileId: string | null properties: MixanJson } @@ -12,8 +12,8 @@ export type ProfilePayload = { last_name?: string email?: string avatar?: string - id: string - properties: MixanJson + id?: string + properties?: MixanJson } export type ProfileIncrementPayload = { @@ -59,13 +59,11 @@ export type MixanIssue = { value: any } -export type MixanIssuesResponse = { - issues: Array, -} - export type MixanErrorResponse = { - code: string + status: 'error' + code: number message: string + issues: Array } export type MixanResponse = {