refactor api and sdk
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"prisma": "^5.4.2",
|
"prisma": "^5.4.2",
|
||||||
|
"random-animal-name": "^0.1.1",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "profiles_project_id_external_id_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "profiles" ALTER COLUMN "external_id" DROP NOT NULL;
|
||||||
@@ -69,7 +69,7 @@ model Event {
|
|||||||
|
|
||||||
model Profile {
|
model Profile {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
external_id String
|
external_id String?
|
||||||
first_name String?
|
first_name String?
|
||||||
last_name String?
|
last_name String?
|
||||||
email String?
|
email String?
|
||||||
@@ -82,7 +82,6 @@ model Profile {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
@@unique([project_id, external_id])
|
|
||||||
@@map("profiles")
|
@@map("profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import express from 'express'
|
import express, { ErrorRequestHandler } from 'express'
|
||||||
import events from './routes/events'
|
import events from './routes/events'
|
||||||
import profiles from './routes/profiles'
|
import profiles from './routes/profiles'
|
||||||
import { authMiddleware } from './middlewares/auth'
|
import { authMiddleware } from './middlewares/auth'
|
||||||
import morgan from 'morgan'
|
import morgan from 'morgan'
|
||||||
import { setup } from './routes/setup'
|
import { setup } from './routes/setup'
|
||||||
|
import { errorHandler } from './middlewares/errors'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const port = process.env.PORT || 8080
|
const port = process.env.PORT || 8080
|
||||||
@@ -21,6 +22,9 @@ if (process.env.SETUP) {
|
|||||||
app.use(authMiddleware)
|
app.use(authMiddleware)
|
||||||
app.use('/api/sdk', events)
|
app.use('/api/sdk', events)
|
||||||
app.use('/api/sdk', profiles)
|
app.use('/api/sdk', profiles)
|
||||||
|
|
||||||
|
app.use(errorHandler)
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Listening on port ${port}...`)
|
console.log(`Listening on port ${port}...`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { NextFunction, Request, Response } from "express"
|
import { NextFunction, Request, Response } from "express"
|
||||||
import { db } from "../db"
|
import { db } from "../db"
|
||||||
|
import { createError } from "../responses/errors"
|
||||||
|
|
||||||
export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||||
const secret = req.headers['mixan-client-secret'] as string | undefined
|
const secret = req.headers['mixan-client-secret'] as string | undefined
|
||||||
|
|
||||||
if(!secret) {
|
if(!secret) {
|
||||||
return res.status(401).json({
|
return next(createError(401, 'Misisng client secret'))
|
||||||
code: 'UNAUTHORIZED',
|
|
||||||
message: 'Missing client secret',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await db.client.findFirst({
|
const client = await db.client.findFirst({
|
||||||
@@ -18,10 +16,7 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc
|
|||||||
})
|
})
|
||||||
|
|
||||||
if(!client) {
|
if(!client) {
|
||||||
return res.status(401).json({
|
return next(createError(401, 'Invalid client secret'))
|
||||||
code: 'UNAUTHORIZED',
|
|
||||||
message: 'Invalid client secret',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
req.client = {
|
req.client = {
|
||||||
|
|||||||
16
apps/backend/src/middlewares/errors.ts
Normal file
16
apps/backend/src/middlewares/errors.ts
Normal file
@@ -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);
|
||||||
|
};
|
||||||
@@ -1,40 +1,38 @@
|
|||||||
import {
|
import {
|
||||||
MixanIssue,
|
MixanIssue,
|
||||||
MixanErrorResponse,
|
MixanErrorResponse
|
||||||
MixanIssuesResponse,
|
|
||||||
} from '@mixan/types'
|
} from '@mixan/types'
|
||||||
|
|
||||||
export function issues(arr: Array<MixanIssue>): MixanIssuesResponse {
|
export class HttpError extends Error {
|
||||||
return {
|
public status: number
|
||||||
issues: arr.map((item) => {
|
public message: string
|
||||||
return {
|
public issues: MixanIssue[]
|
||||||
field: item.field,
|
|
||||||
message: item.message,
|
constructor(status: number, message: string | Error, issues?: MixanIssue[]) {
|
||||||
value: item.value,
|
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 {
|
export function createIssues(arr: Array<MixanIssue>) {
|
||||||
if (error instanceof Error) {
|
throw new HttpError(400, 'Issues', arr)
|
||||||
return {
|
}
|
||||||
code: 'Error',
|
|
||||||
message: error.message,
|
export function createError(status = 500, error: unknown | Error | string) {
|
||||||
}
|
if(error instanceof Error || typeof error === 'string') {
|
||||||
}
|
return new HttpError(status, error)
|
||||||
|
}
|
||||||
// @ts-ignore
|
|
||||||
if ('message' in error) {
|
return new HttpError(500, 'Unexpected error occured')
|
||||||
return {
|
|
||||||
code: 'UnknownError',
|
|
||||||
// @ts-ignore
|
|
||||||
message: error.message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: 'UnknownError',
|
|
||||||
message: 'Unknown error',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,41 @@
|
|||||||
import {Router} from 'express'
|
import {NextFunction, Response, Router} from 'express'
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { MixanRequest } from '../types/express';
|
import { MixanRequest } from '../types/express';
|
||||||
import { EventPayload } from '@mixan/types';
|
import { EventPayload } from '@mixan/types';
|
||||||
import { getEvents, getProfileIdFromEvents } from '../services/event';
|
import { getEvents } from '../services/event';
|
||||||
import { success } from '../responses/success';
|
import { success } from '../responses/success';
|
||||||
import { makeError } from '../responses/errors';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
type PostRequest = MixanRequest<Array<EventPayload>>
|
type PostRequest = MixanRequest<Array<EventPayload>>
|
||||||
|
|
||||||
router.get('/events', async (req, res) => {
|
router.get('/events', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const events = await getEvents(req.client.project_id)
|
const events = await getEvents(req.client.project_id)
|
||||||
res.json(success(events))
|
res.json(success(events))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json(makeError(error))
|
next(error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/events', async (req: PostRequest, res) => {
|
router.post('/events', async (req: PostRequest, res: Response, next: NextFunction) => {
|
||||||
const projectId = req.client.project_id
|
try {
|
||||||
const profileId = await getProfileIdFromEvents(projectId, req.body)
|
const projectId = req.client.project_id
|
||||||
|
|
||||||
await db.event.createMany({
|
await db.event.createMany({
|
||||||
data: req.body.map(event => ({
|
data: req.body.map((event) => ({
|
||||||
name: event.name,
|
name: event.name,
|
||||||
properties: event.properties,
|
properties: event.properties,
|
||||||
createdAt: event.time,
|
createdAt: event.time,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
profile_id: profileId,
|
profile_id: event.profileId,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
res.status(201).json(success())
|
res.status(201).json(success())
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
@@ -1,132 +1,131 @@
|
|||||||
import { Router } from 'express'
|
import { NextFunction, Response, Router } from 'express'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { MixanRequest } from '../types/express'
|
import { MixanRequest } from '../types/express'
|
||||||
import {
|
import { getProfile, tickProfileProperty } from '../services/profile'
|
||||||
createProfile,
|
|
||||||
getProfileByExternalId,
|
|
||||||
updateProfile,
|
|
||||||
} from '../services/profile'
|
|
||||||
import {
|
import {
|
||||||
ProfileDecrementPayload,
|
ProfileDecrementPayload,
|
||||||
ProfileIncrementPayload,
|
ProfileIncrementPayload,
|
||||||
ProfilePayload,
|
ProfilePayload,
|
||||||
} from '@mixan/types'
|
} from '@mixan/types'
|
||||||
import { issues } from '../responses/errors'
|
|
||||||
import { success } from '../responses/success'
|
import { success } from '../responses/success'
|
||||||
|
import randomAnimalName from 'random-animal-name'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
type PostRequest = MixanRequest<ProfilePayload>
|
type PostRequest = MixanRequest<ProfilePayload>
|
||||||
|
|
||||||
router.get('/profiles', async (req, res) => {
|
router.get('/profiles', async (req, res, next) => {
|
||||||
res.json(success(await db.profile.findMany({
|
try {
|
||||||
where: {
|
res.json(
|
||||||
project_id: req.client.project_id,
|
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)
|
} catch (error) {
|
||||||
if (profile) {
|
next(error)
|
||||||
await updateProfile(projectId, body.id, body, profile)
|
|
||||||
} else {
|
|
||||||
await createProfile(projectId, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(profile ? 200 : 201).json(success())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/profiles/increment',
|
'/profiles',
|
||||||
async (req: MixanRequest<ProfileIncrementPayload>, res) => {
|
async (
|
||||||
const body = req.body
|
req: MixanRequest<{
|
||||||
const projectId = req.client.project_id
|
id: string
|
||||||
const profile = await getProfileByExternalId(projectId, body.id)
|
properties?: Record<string, any>
|
||||||
|
}>,
|
||||||
if (profile) {
|
res: Response,
|
||||||
const existingProperties = (
|
next: NextFunction
|
||||||
typeof profile.properties === 'object' ? profile.properties || {} : {}
|
) => {
|
||||||
) as Record<string, number>
|
try {
|
||||||
const value =
|
const projectId = req.client.project_id
|
||||||
body.name in existingProperties ? existingProperties[body.name] : 0
|
const { id, properties } = req.body
|
||||||
const properties = {
|
const profile = await db.profile.create({
|
||||||
...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,
|
|
||||||
},
|
|
||||||
data: {
|
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(
|
router.put('/profiles/:id', async (req: PostRequest, res: Response, next: NextFunction) => {
|
||||||
'/profiles/decrement',
|
try {
|
||||||
async (req: MixanRequest<ProfileDecrementPayload>, res) => {
|
const profileId = req.params.id
|
||||||
const body = req.body
|
const profile = await getProfile(profileId)
|
||||||
const projectId = req.client.project_id
|
const { body } = req
|
||||||
const profile = await getProfileByExternalId(projectId, body.id)
|
|
||||||
|
|
||||||
if (profile) {
|
if (profile) {
|
||||||
const existingProperties = (
|
await db.profile.update({
|
||||||
typeof profile.properties === 'object' ? profile.properties || {} : {}
|
|
||||||
) as Record<string, number>
|
|
||||||
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({
|
|
||||||
where: {
|
where: {
|
||||||
external_id: String(body.id),
|
id: profileId,
|
||||||
project_id: req.client.project_id,
|
|
||||||
},
|
},
|
||||||
data: {
|
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<ProfileIncrementPayload>, 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<ProfileDecrementPayload>, 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Request, Response } from 'express'
|
import { NextFunction, Request, Response } from 'express'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { makeError } from '../responses/errors'
|
|
||||||
import { v4 as uuid } from 'uuid'
|
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 {
|
try {
|
||||||
const organization = await db.organization.create({
|
const organization = await db.organization.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -22,7 +21,7 @@ export async function setup(req: Request, res: Response) {
|
|||||||
data: {
|
data: {
|
||||||
name: 'Acme Website Client',
|
name: 'Acme Website Client',
|
||||||
project_id: project.id,
|
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,
|
client,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json(makeError(error))
|
next(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { EventPayload } from "@mixan/types";
|
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
|
|
||||||
export function getEvents(projectId: string) {
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,15 @@
|
|||||||
import { EventPayload, ProfilePayload } from "@mixan/types";
|
import { EventPayload, ProfilePayload } from '@mixan/types'
|
||||||
import { db } from "../db";
|
import { db } from '../db'
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from '@prisma/client'
|
||||||
|
import { HttpError } from '../responses/errors'
|
||||||
|
|
||||||
export function createProfile(projectId: string, payload: ProfilePayload) {
|
type DbProfile = Exclude<Prisma.PromiseReturnType<typeof getProfile>, null>
|
||||||
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<Prisma.PromiseReturnType<typeof getProfileByExternalId>, null>
|
export function getProfile(id: string) {
|
||||||
|
return db.profile.findUniqueOrThrow({
|
||||||
export function getProfileByExternalId(projectId: string, externalId: string) {
|
|
||||||
return db.profile.findUnique({
|
|
||||||
where: {
|
where: {
|
||||||
project_id_external_id: {
|
id,
|
||||||
project_id: projectId,
|
},
|
||||||
external_id: externalId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,42 +17,47 @@ export function getProfiles(projectId: string) {
|
|||||||
return db.profile.findMany({
|
return db.profile.findMany({
|
||||||
where: {
|
where: {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProfile(projectId: string, profileId: string, payload: Omit<ProfilePayload, 'id'>, oldProfile: DbProfile) {
|
export async function tickProfileProperty({
|
||||||
const { email, first_name, last_name, avatar, properties } = payload
|
profileId,
|
||||||
return db.profile.update({
|
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<string, number>
|
||||||
|
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: {
|
where: {
|
||||||
project_id_external_id: {
|
id: profileId,
|
||||||
project_id: projectId,
|
|
||||||
external_id: profileId,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
email,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
avatar,
|
|
||||||
properties: {
|
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
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export type MixanRequest<Body> = Omit<Express.Request,'body'> & {
|
import { Request } from "express"
|
||||||
|
|
||||||
|
export type MixanRequest<Body> = Omit<Request,'body'> & {
|
||||||
body: Body
|
body: Body
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
import {
|
import {
|
||||||
EventPayload,
|
EventPayload,
|
||||||
MixanErrorResponse,
|
MixanErrorResponse,
|
||||||
MixanIssuesResponse,
|
|
||||||
MixanResponse,
|
MixanResponse,
|
||||||
ProfilePayload,
|
ProfilePayload,
|
||||||
} from '@mixan/types'
|
} from '@mixan/types'
|
||||||
@@ -12,6 +12,9 @@ type MixanOptions = {
|
|||||||
batchInterval?: number
|
batchInterval?: number
|
||||||
maxBatchSize?: number
|
maxBatchSize?: number
|
||||||
verbose?: boolean
|
verbose?: boolean
|
||||||
|
saveProfileId: (profileId: string) => void,
|
||||||
|
getProfileId: () => string | null,
|
||||||
|
removeProfileId: () => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
class Fetcher {
|
class Fetcher {
|
||||||
@@ -25,7 +28,11 @@ class Fetcher {
|
|||||||
this.logger = options.verbose ? console.log : () => {}
|
this.logger = options.verbose ? console.log : () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
post(path: string, data: Record<string, any>) {
|
post(
|
||||||
|
path: string,
|
||||||
|
data: Record<string, any>,
|
||||||
|
options: FetchRequestInit = {}
|
||||||
|
) {
|
||||||
const url = `${this.url}${path}`
|
const url = `${this.url}${path}`
|
||||||
this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2))
|
this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2))
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
@@ -35,30 +42,19 @@ class Fetcher {
|
|||||||
},
|
},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
...options,
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const response = await res.json<
|
const response = await res.json<
|
||||||
MixanIssuesResponse | MixanErrorResponse | MixanResponse<unknown>
|
MixanErrorResponse | MixanResponse<unknown>
|
||||||
>()
|
>()
|
||||||
if ('status' in response && response.status === 'ok') {
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('code' in response) {
|
if('status' in response && response.status === 'error') {
|
||||||
this.logger(`Mixan error: [${response.code}] ${response.message}`)
|
this.logger(`Mixan request failed: ${url}`, JSON.stringify(response, null, 2))
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('issues' in response) {
|
return response
|
||||||
this.logger(`Mixan issues:`)
|
|
||||||
response.issues.forEach((issue) => {
|
|
||||||
this.logger(` - ${issue.message} (${issue.value})`)
|
|
||||||
})
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
return null
|
return null
|
||||||
@@ -99,7 +95,7 @@ class Batcher<T extends any> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.queue.length > this.maxBatchSize) {
|
if (this.queue.length >= this.maxBatchSize) {
|
||||||
this.send()
|
this.send()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,16 +112,21 @@ class Batcher<T extends any> {
|
|||||||
export class Mixan {
|
export class Mixan {
|
||||||
private fetch: Fetcher
|
private fetch: Fetcher
|
||||||
private eventBatcher: Batcher<EventPayload>
|
private eventBatcher: Batcher<EventPayload>
|
||||||
private profile: ProfilePayload | null = null
|
private profileId?: string
|
||||||
|
private options: MixanOptions
|
||||||
|
private logger: (...args: any[]) => void
|
||||||
|
|
||||||
constructor(options: MixanOptions) {
|
constructor(options: MixanOptions) {
|
||||||
|
this.logger = options.verbose ? console.log : () => {}
|
||||||
|
this.options = options
|
||||||
this.fetch = new Fetcher(options)
|
this.fetch = new Fetcher(options)
|
||||||
|
this.setAnonymousUser()
|
||||||
this.eventBatcher = new Batcher(options, (queue) => {
|
this.eventBatcher = new Batcher(options, (queue) => {
|
||||||
this.fetch.post(
|
this.fetch.post(
|
||||||
'/events',
|
'/events',
|
||||||
queue.map((item) => ({
|
queue.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
externalId: item.externalId || this.profile?.id,
|
profileId: item.profileId || this.profileId || null,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -140,18 +141,34 @@ export class Mixan {
|
|||||||
name,
|
name,
|
||||||
properties,
|
properties,
|
||||||
time: this.timestamp(),
|
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) {
|
async setUser(profile: ProfilePayload) {
|
||||||
this.profile = profile
|
await this.fetch.post(`/profiles/${this.profileId}`, profile, {
|
||||||
await this.fetch.post('/profiles', profile)
|
method: 'PUT'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async setUserProperty(name: string, value: any) {
|
async setUserProperty(name: string, value: any) {
|
||||||
await this.fetch.post('/profiles', {
|
await this.fetch.post(`/profiles/${this.profileId}`, {
|
||||||
...this.profile,
|
|
||||||
properties: {
|
properties: {
|
||||||
[name]: value,
|
[name]: value,
|
||||||
},
|
},
|
||||||
@@ -159,33 +176,41 @@ export class Mixan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async increment(name: string, value: number = 1) {
|
async increment(name: string, value: number = 1) {
|
||||||
if (!this.profile) {
|
if (!this.profileId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.fetch.post('/profiles/increment', {
|
await this.fetch.post(`/profiles/${this.profileId}/increment`, {
|
||||||
id: this.profile.id,
|
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
|
}, {
|
||||||
|
method: 'PUT'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async decrement(name: string, value: number = 1) {
|
async decrement(name: string, value: number = 1) {
|
||||||
if (!this.profile) {
|
if (!this.profileId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.fetch.post('/profiles/decrement', {
|
await this.fetch.post(`/profiles/${this.profileId}/decrement`, {
|
||||||
id: this.profile.id,
|
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
|
}, {
|
||||||
|
method: 'PUT'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
screenView(route: string, properties?: Record<string, any>) {
|
async screenView(route: string, properties?: Record<string, any>) {
|
||||||
this.event('screen_view', {
|
await this.event('screen_view', {
|
||||||
...(properties || {}),
|
...(properties || {}),
|
||||||
route,
|
route,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.eventBatcher.flush()
|
||||||
|
this.options.removeProfileId()
|
||||||
|
this.profileId = undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mixan/types": "workspace:*"
|
"@mixan/types": "workspace:*",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/uuid": "^9.0.5",
|
||||||
"bun-types": "latest",
|
"bun-types": "latest",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export type MixanJson = Record<string, any>
|
|||||||
export type EventPayload = {
|
export type EventPayload = {
|
||||||
name: string
|
name: string
|
||||||
time: string
|
time: string
|
||||||
externalId: string | null
|
profileId: string | null
|
||||||
properties: MixanJson
|
properties: MixanJson
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@ export type ProfilePayload = {
|
|||||||
last_name?: string
|
last_name?: string
|
||||||
email?: string
|
email?: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
id: string
|
id?: string
|
||||||
properties: MixanJson
|
properties?: MixanJson
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProfileIncrementPayload = {
|
export type ProfileIncrementPayload = {
|
||||||
@@ -59,13 +59,11 @@ export type MixanIssue = {
|
|||||||
value: any
|
value: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MixanIssuesResponse = {
|
|
||||||
issues: Array<MixanIssue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MixanErrorResponse = {
|
export type MixanErrorResponse = {
|
||||||
code: string
|
status: 'error'
|
||||||
|
code: number
|
||||||
message: string
|
message: string
|
||||||
|
issues: Array<MixanIssue>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MixanResponse<T> = {
|
export type MixanResponse<T> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user