From 903fd155c3fe514a4b6aad694bacf6be7ae127b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 11 Oct 2023 12:34:35 +0200 Subject: [PATCH] init --- .gitignore | 176 ++++++++++++++++ README.md | 59 ++++++ apps/backend/@types/express/index.d.ts | 15 ++ apps/backend/package.json | 27 +++ .../20231010091416_init/migration.sql | 59 ++++++ .../20231010094459_add_dates/migration.sql | 19 ++ .../20231010184810_fix_profile/migration.sql | 10 + .../migration.sql | 5 + .../20231010195623_add_client/migration.sql | 12 ++ .../migration.sql | 3 + .../migration.sql | 11 + .../migration.sql | 8 + .../migration.sql | 10 + .../migration.sql | 8 + .../prisma/migrations/migration_lock.toml | 3 + apps/backend/prisma/schema.prisma | 100 +++++++++ apps/backend/src/app.ts | 41 ++++ apps/backend/src/db.ts | 3 + apps/backend/src/middlewares/auth.ts | 33 +++ apps/backend/src/responses/errors.ts | 13 ++ apps/backend/src/responses/success.ts | 8 + apps/backend/src/routes/events.ts | 34 ++++ apps/backend/src/routes/profiles.ts | 133 ++++++++++++ apps/backend/src/services/event.ts | 29 +++ apps/backend/src/services/password.ts | 7 + apps/backend/src/services/profile.ts | 75 +++++++ apps/backend/src/types/express.ts | 3 + apps/backend/tsconfig.json | 22 ++ bun.lockb | Bin 0 -> 77312 bytes package.json | 23 +++ packages/sdk/index.ts | 191 ++++++++++++++++++ packages/sdk/package.json | 13 ++ packages/sdk/tsconfig.json | 25 +++ packages/sdk/tsup.config.ts | 10 + packages/types/README.md | 15 ++ packages/types/index.ts | 74 +++++++ packages/types/package.json | 12 ++ packages/types/tsconfig.json | 22 ++ packages/types/tsup.config.ts | 10 + publish.ts | 64 ++++++ 40 files changed, 1385 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/backend/@types/express/index.d.ts create mode 100644 apps/backend/package.json create mode 100644 apps/backend/prisma/migrations/20231010091416_init/migration.sql create mode 100644 apps/backend/prisma/migrations/20231010094459_add_dates/migration.sql create mode 100644 apps/backend/prisma/migrations/20231010184810_fix_profile/migration.sql create mode 100644 apps/backend/prisma/migrations/20231010185023_add_profile_properties/migration.sql create mode 100644 apps/backend/prisma/migrations/20231010195623_add_client/migration.sql create mode 100644 apps/backend/prisma/migrations/20231010195805_add_timestamps_on_client/migration.sql create mode 100644 apps/backend/prisma/migrations/20231010202343_add_profile_id_on_event/migration.sql create mode 100644 apps/backend/prisma/migrations/20231010202552_profile_nullable_on_events/migration.sql create mode 100644 apps/backend/prisma/migrations/20231011063223_rename_profile_id_to_external/migration.sql create mode 100644 apps/backend/prisma/migrations/20231011064100_add_unique_external_id_and_project_id/migration.sql create mode 100644 apps/backend/prisma/migrations/migration_lock.toml create mode 100644 apps/backend/prisma/schema.prisma create mode 100644 apps/backend/src/app.ts create mode 100644 apps/backend/src/db.ts create mode 100644 apps/backend/src/middlewares/auth.ts create mode 100644 apps/backend/src/responses/errors.ts create mode 100644 apps/backend/src/responses/success.ts create mode 100644 apps/backend/src/routes/events.ts create mode 100644 apps/backend/src/routes/profiles.ts create mode 100644 apps/backend/src/services/event.ts create mode 100644 apps/backend/src/services/password.ts create mode 100644 apps/backend/src/services/profile.ts create mode 100644 apps/backend/src/types/express.ts create mode 100644 apps/backend/tsconfig.json create mode 100755 bun.lockb create mode 100644 package.json create mode 100644 packages/sdk/index.ts create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/tsconfig.json create mode 100644 packages/sdk/tsup.config.ts create mode 100644 packages/types/README.md create mode 100644 packages/types/index.ts create mode 100644 packages/types/package.json create mode 100644 packages/types/tsconfig.json create mode 100644 packages/types/tsup.config.ts create mode 100644 publish.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ab5afb29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + diff --git a/README.md b/README.md new file mode 100644 index 00000000..cadc9d2b --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# mixan + +Mixan is a simple analytics tool for logging events on web and react-native. My goal is to make a minimal mixpanel copy with the most basic features (for now). + +## @mixan/sdk + +For pushing events + +### Install + +- npm: `npm install @mixan/sdk` +- pnpm: `pnpm add @mixan/sdk` +- yarn: `yarn add @mixan/sdk` + +### Usage + +```ts +import { Mixan } from '@mixan/sdk'; + +const mixan = new Mixan({ + clientSecret: '9fb405d2-7e16-489f-980c-67b25a6eab97', + url: 'http://localhost:8080', + batchInterval: 10000, + verbose: false +}) + +mixan.setUser({ + id: 'id', + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@gmail.com', + properties: {} // any properties +}) + +// will upsert 'app_open' on user property and increment it +mixan.increment('app_open') +// will upsert 'app_open' on user property and increment it by 10 +mixan.increment('app_open', 10) +// will upsert 'app_open' on user property and decrement it by 2 +mixan.decrement('app_open', 2) + +// send a sign_in event +mixan.event('sign_in') + +// send a sign_in event with properties +mixan.event('sign_in', { + provider: 'gmail' +}) + +// short hand for 'screen_view', can also take any properties +mixan.screenView('Profile', { + id: '123', + // any other properties, url, public +}) +``` + +## @mixan/backend + +Self hosted service for collecting all events. Dockerfile and GUI will be added soon. \ No newline at end of file diff --git a/apps/backend/@types/express/index.d.ts b/apps/backend/@types/express/index.d.ts new file mode 100644 index 00000000..35b994da --- /dev/null +++ b/apps/backend/@types/express/index.d.ts @@ -0,0 +1,15 @@ +export {} + +declare global { + // metadata-scraper relies on this type + type Element = any + + // add context to request + namespace Express { + interface Request { + client: { + project_id: string + } + } + } +} diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 00000000..b116d295 --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "backend", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "bun src/app.ts", + "dev": "bun --watch src/app.ts", + "codegen": "bunx prisma generate" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@mixan/types": "workspace:*", + "@prisma/client": "^5.4.2", + "express": "^4.18.2", + "morgan": "^1.10.0", + "prisma": "^5.4.2", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/express": "^4.17.18", + "@types/morgan": "^1.9.6", + "bun-types": "^1.0.5-canary.20231009T140142" + } +} diff --git a/apps/backend/prisma/migrations/20231010091416_init/migration.sql b/apps/backend/prisma/migrations/20231010091416_init/migration.sql new file mode 100644 index 00000000..3fb3b5d4 --- /dev/null +++ b/apps/backend/prisma/migrations/20231010091416_init/migration.sql @@ -0,0 +1,59 @@ +-- CreateTable +CREATE TABLE "organizations" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + + CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "projects" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "organization_id" UUID NOT NULL, + + CONSTRAINT "projects_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "organization_id" UUID NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "events" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "properties" JSONB NOT NULL, + "project_id" UUID NOT NULL, + + CONSTRAINT "events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "profiles" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "properties" JSONB NOT NULL, + "project_id" UUID NOT NULL, + + CONSTRAINT "profiles_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "projects" ADD CONSTRAINT "projects_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "events" ADD CONSTRAINT "events_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20231010094459_add_dates/migration.sql b/apps/backend/prisma/migrations/20231010094459_add_dates/migration.sql new file mode 100644 index 00000000..81e72e4b --- /dev/null +++ b/apps/backend/prisma/migrations/20231010094459_add_dates/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "events" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "organizations" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "profiles" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "projects" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/backend/prisma/migrations/20231010184810_fix_profile/migration.sql b/apps/backend/prisma/migrations/20231010184810_fix_profile/migration.sql new file mode 100644 index 00000000..1eca309e --- /dev/null +++ b/apps/backend/prisma/migrations/20231010184810_fix_profile/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `profiles` table. All the data in the column will be lost. + - Added the required column `profile_id` to the `profiles` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "profiles" DROP COLUMN "name", +ADD COLUMN "profile_id" TEXT NOT NULL; diff --git a/apps/backend/prisma/migrations/20231010185023_add_profile_properties/migration.sql b/apps/backend/prisma/migrations/20231010185023_add_profile_properties/migration.sql new file mode 100644 index 00000000..fdd80823 --- /dev/null +++ b/apps/backend/prisma/migrations/20231010185023_add_profile_properties/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "profiles" ADD COLUMN "avatar" TEXT, +ADD COLUMN "email" TEXT, +ADD COLUMN "first_name" TEXT, +ADD COLUMN "last_name" TEXT; diff --git a/apps/backend/prisma/migrations/20231010195623_add_client/migration.sql b/apps/backend/prisma/migrations/20231010195623_add_client/migration.sql new file mode 100644 index 00000000..5d28ac9e --- /dev/null +++ b/apps/backend/prisma/migrations/20231010195623_add_client/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "clients" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "secret" TEXT NOT NULL, + "project_id" UUID NOT NULL, + + CONSTRAINT "clients_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "clients" ADD CONSTRAINT "clients_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20231010195805_add_timestamps_on_client/migration.sql b/apps/backend/prisma/migrations/20231010195805_add_timestamps_on_client/migration.sql new file mode 100644 index 00000000..1a9aea58 --- /dev/null +++ b/apps/backend/prisma/migrations/20231010195805_add_timestamps_on_client/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "clients" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/backend/prisma/migrations/20231010202343_add_profile_id_on_event/migration.sql b/apps/backend/prisma/migrations/20231010202343_add_profile_id_on_event/migration.sql new file mode 100644 index 00000000..c8709ccc --- /dev/null +++ b/apps/backend/prisma/migrations/20231010202343_add_profile_id_on_event/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `profile_id` to the `events` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "events" ADD COLUMN "profile_id" UUID NOT NULL; + +-- AddForeignKey +ALTER TABLE "events" ADD CONSTRAINT "events_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20231010202552_profile_nullable_on_events/migration.sql b/apps/backend/prisma/migrations/20231010202552_profile_nullable_on_events/migration.sql new file mode 100644 index 00000000..511459c8 --- /dev/null +++ b/apps/backend/prisma/migrations/20231010202552_profile_nullable_on_events/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "events" DROP CONSTRAINT "events_profile_id_fkey"; + +-- AlterTable +ALTER TABLE "events" ALTER COLUMN "profile_id" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "events" ADD CONSTRAINT "events_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20231011063223_rename_profile_id_to_external/migration.sql b/apps/backend/prisma/migrations/20231011063223_rename_profile_id_to_external/migration.sql new file mode 100644 index 00000000..3da7a1d7 --- /dev/null +++ b/apps/backend/prisma/migrations/20231011063223_rename_profile_id_to_external/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `profile_id` on the `profiles` table. All the data in the column will be lost. + - Added the required column `external_id` to the `profiles` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "profiles" DROP COLUMN "profile_id", +ADD COLUMN "external_id" TEXT NOT NULL; diff --git a/apps/backend/prisma/migrations/20231011064100_add_unique_external_id_and_project_id/migration.sql b/apps/backend/prisma/migrations/20231011064100_add_unique_external_id_and_project_id/migration.sql new file mode 100644 index 00000000..eafa2140 --- /dev/null +++ b/apps/backend/prisma/migrations/20231011064100_add_unique_external_id_and_project_id/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[project_id,external_id]` on the table `profiles` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "profiles_project_id_external_id_key" ON "profiles"("project_id", "external_id"); diff --git a/apps/backend/prisma/migrations/migration_lock.toml b/apps/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/apps/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma new file mode 100644 index 00000000..0365e482 --- /dev/null +++ b/apps/backend/prisma/schema.prisma @@ -0,0 +1,100 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Organization { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + projects Project[] + users User[] + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("organizations") +} + +model Project { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + organization_id String @db.Uuid + organization Organization @relation(fields: [organization_id], references: [id]) + events Event[] + profiles Profile[] + clients Client[] + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("projects") +} + +model User { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + email String + password String + organization_id String @db.Uuid + organization Organization @relation(fields: [organization_id], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("users") +} + +model Event { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + properties Json + project_id String @db.Uuid + project Project @relation(fields: [project_id], references: [id]) + + profile_id String? @db.Uuid + profile Profile? @relation(fields: [profile_id], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("events") +} + +model Profile { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + external_id String + first_name String? + last_name String? + email String? + avatar String? + properties Json + project_id String @db.Uuid + project Project @relation(fields: [project_id], references: [id]) + events Event[] + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@unique([project_id, external_id]) + @@map("profiles") +} + +model Client { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + secret String + project_id String @db.Uuid + project Project @relation(fields: [project_id], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("clients") +} diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts new file mode 100644 index 00000000..3777d175 --- /dev/null +++ b/apps/backend/src/app.ts @@ -0,0 +1,41 @@ +import express from "express"; +import events from './routes/events' +import profiles from './routes/profiles' +import { authMiddleware } from "./middlewares/auth"; +import morgan from 'morgan' +import { db } from "./db"; +import { hashPassword } from "./services/password"; +import { v4 as uuid } from 'uuid'; + + +const app = express(); +const port = 8080; + +app.use(morgan('tiny')) +app.use(express.json()); +app.use(express.json()); +app.use(authMiddleware) + + +app.use(events) +app.use(profiles) +app.get("/ping", (req, res) => res.json("pong")); +app.listen(port, () => { + console.log(`Listening on port ${port}...`); +}); + +// async function main() { +// const secret = uuid() +// await db.client.create({ +// data: { +// project_id: 'eed345ae-2772-42e5-b989-e36e09c5febc', +// name: 'test', +// secret: await hashPassword(secret), +// } +// }) +// console.log('Your secret is', secret); + + +// } + +// main() \ No newline at end of file diff --git a/apps/backend/src/db.ts b/apps/backend/src/db.ts new file mode 100644 index 00000000..dff3b3e9 --- /dev/null +++ b/apps/backend/src/db.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export const db = new PrismaClient(); diff --git a/apps/backend/src/middlewares/auth.ts b/apps/backend/src/middlewares/auth.ts new file mode 100644 index 00000000..d7517105 --- /dev/null +++ b/apps/backend/src/middlewares/auth.ts @@ -0,0 +1,33 @@ +import { NextFunction, Request, Response } from "express" +import { db } from "../db" +import { verifyPassword } from "../services/password" + +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', + }) + } + + const client = await db.client.findFirst({ + where: { + secret, + }, + }) + + if(!client) { + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'Invalid client secret', + }) + } + + req.client = { + project_id: client.project_id, + } + + next() +} \ No newline at end of file diff --git a/apps/backend/src/responses/errors.ts b/apps/backend/src/responses/errors.ts new file mode 100644 index 00000000..3889abec --- /dev/null +++ b/apps/backend/src/responses/errors.ts @@ -0,0 +1,13 @@ +import { MixanIssue, MixanIssuesResponse } from "@mixan/types"; + +export function issues(arr: Array): MixanIssuesResponse { + return { + issues: arr.map((item) => { + return { + field: item.field, + message: item.message, + value: item.value, + }; + }) + } +} \ No newline at end of file diff --git a/apps/backend/src/responses/success.ts b/apps/backend/src/responses/success.ts new file mode 100644 index 00000000..94ce898f --- /dev/null +++ b/apps/backend/src/responses/success.ts @@ -0,0 +1,8 @@ +import { MixanResponse } from "@mixan/types"; + +export function success(result?: T): MixanResponse { + return { + result: result || null, + status: 'ok' + } +} \ No newline at end of file diff --git a/apps/backend/src/routes/events.ts b/apps/backend/src/routes/events.ts new file mode 100644 index 00000000..4d22a977 --- /dev/null +++ b/apps/backend/src/routes/events.ts @@ -0,0 +1,34 @@ +import {Router} from 'express' +import { db } from '../db'; +import { MixanRequest } from '../types/express'; +import { EventPayload } from '@mixan/types'; +import { getEvents, getProfileIdFromEvents } from '../services/event'; +import { success } from '../responses/success'; + +const router = Router(); + +type PostRequest = MixanRequest> + +router.get('/events', async (req, res) => { + const events = await getEvents(req.client.project_id) + res.json(success(events)) +}) + +router.post('/events', async (req: PostRequest, res) => { + const projectId = req.client.project_id + const profileId = await getProfileIdFromEvents(projectId, req.body) + + 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()) +}) + +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 new file mode 100644 index 00000000..2dafdc36 --- /dev/null +++ b/apps/backend/src/routes/profiles.ts @@ -0,0 +1,133 @@ +import { Router } from 'express' +import { db } from '../db' +import { MixanRequest } from '../types/express' +import { + createProfile, + getProfileByExternalId, + updateProfile, +} from '../services/profile' +import { + ProfileDecrementPayload, + ProfileIncrementPayload, + ProfilePayload, +} from '@mixan/types' +import { issues } from '../responses/errors' +import { success } from '../responses/success' + +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) + } + + 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, + }, + data: { + properties, + }, + }) + } + + res.status(200).json(success()) + } +) + +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) + + 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({ + where: { + external_id: String(body.id), + project_id: req.client.project_id, + }, + data: { + properties, + }, + }) + } + + res.status(200).json(success()) + } +) + +export default router diff --git a/apps/backend/src/services/event.ts b/apps/backend/src/services/event.ts new file mode 100644 index 00000000..3008e44f --- /dev/null +++ b/apps/backend/src/services/event.ts @@ -0,0 +1,29 @@ +import { EventPayload } from "@mixan/types"; +import { db } from "../db"; + +export function getEvents(projectId: string) { + return db.event.findMany({ + where: { + project_id: projectId, + } + }) +} + +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 new file mode 100644 index 00000000..a084a350 --- /dev/null +++ b/apps/backend/src/services/password.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000..363d7854 --- /dev/null +++ b/apps/backend/src/services/profile.ts @@ -0,0 +1,75 @@ +import { EventPayload, ProfilePayload } from "@mixan/types"; +import { db } from "../db"; +import { Prisma } from "@prisma/client"; + +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> + +export function getProfileByExternalId(projectId: string, externalId: string) { + return db.profile.findUnique({ + where: { + project_id_external_id: { + project_id: projectId, + external_id: externalId, + } + } + }) +} + +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({ + where: { + project_id_external_id: { + project_id: projectId, + external_id: profileId, + } + }, + data: { + email, + first_name, + last_name, + avatar, + properties: { + ...(typeof oldProfile.properties === 'object' ? oldProfile.properties || {} : {}), + ...(properties || {}), + }, + }, + }) +} + +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 new file mode 100644 index 00000000..f849b01e --- /dev/null +++ b/apps/backend/src/types/express.ts @@ -0,0 +1,3 @@ +export type MixanRequest = Omit & { + body: Body +} \ No newline at end of file diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 00000000..7556e1d4 --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": [ + "bun-types" // add Bun global + ] + } +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..9accf3754fcf341066c636cdbf71c1e95bba1de7 GIT binary patch literal 77312 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 void + + constructor(options: MixanOptions) { + this.url = options.url + this.clientSecret = options.clientSecret + this.logger = options.verbose ? console.log : () => {} + } + + post(path: string, data: Record) { + const url = `${this.url}${path}` + this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2)) + return fetch(url, { + headers: { + ['mixan-client-secret']: this.clientSecret, + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify(data), + }) + .then(async (res) => { + const response = await res.json< + MixanIssuesResponse | MixanErrorResponse | MixanResponse + >() + if ('status' in response && response.status === 'ok') { + return response + } + + if ('code' in response) { + this.logger(`Mixan error: [${response.code}] ${response.message}`) + return null + } + + if ('issues' in response) { + this.logger(`Mixan issues:`) + response.issues.forEach((issue) => { + this.logger(` - ${issue.message} (${issue.value})`) + }) + + return null + } + + return null + }) + .catch(() => { + return null + }) + } +} + +class Batcher { + queue: T[] = [] + timer?: Timer + callback: (queue: T[]) => void + maxBatchSize = 10 + batchInterval = 10000 + + constructor(options: MixanOptions, callback: (queue: T[]) => void) { + this.callback = callback + + if (options.maxBatchSize) { + this.maxBatchSize = options.maxBatchSize + } + + if (options.batchInterval) { + this.batchInterval = options.batchInterval + } + } + + add(payload: T) { + this.queue.push(payload) + this.flush() + } + + flush() { + if (this.timer) { + clearTimeout(this.timer) + } + + if (this.queue.length === 0) { + return + } + + if (this.queue.length > this.maxBatchSize) { + this.send() + return + } + + this.timer = setTimeout(this.send.bind(this), this.batchInterval) + } + + send() { + this.callback(this.queue) + this.queue = [] + } +} + +export class Mixan { + private fetch: Fetcher + private eventBatcher: Batcher + private profile: ProfilePayload | null = null + + constructor(options: MixanOptions) { + this.fetch = new Fetcher(options) + this.eventBatcher = new Batcher(options, (queue) => { + this.fetch.post( + '/events', + queue.map((item) => ({ + ...item, + externalId: item.externalId || this.profile?.id, + })) + ) + }) + } + + timestamp() { + return new Date().toISOString() + } + + event(name: string, properties: Record) { + this.eventBatcher.add({ + name, + properties, + time: this.timestamp(), + externalId: this.profile?.id || null, + }) + } + + async setUser(profile: ProfilePayload) { + this.profile = profile + await this.fetch.post('/profiles', profile) + } + + async setUserProperty(name: string, value: any) { + await this.fetch.post('/profiles', { + ...this.profile, + properties: { + [name]: value, + }, + }) + } + + async increment(name: string, value: number = 1) { + if (!this.profile) { + return + } + + await this.fetch.post('/profiles/increment', { + id: this.profile.id, + name, + value, + }) + } + + async decrement(name: string, value: number = 1) { + if (!this.profile) { + return + } + + await this.fetch.post('/profiles/decrement', { + id: this.profile.id, + name, + value, + }) + } + + screenView(route: string, properties?: Record) { + this.event('screen_view', { + ...(properties || {}), + route, + }) + } +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000..1670bd07 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,13 @@ +{ + "name": "@mixan/sdk", + "version": "0.0.1", + "type": "module", + "module": "index.ts", + "devDependencies": { + "@mixan/types": "workspace:*", + "bun-types": "latest" + }, + "dependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000..fd6a7f5a --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + + "outDir": "dist", + "allowImportingTsExtensions": false, + "noEmit": false, + + "types": [ + "bun-types" // add Bun global + ], + } +} diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts new file mode 100644 index 00000000..045c25df --- /dev/null +++ b/packages/sdk/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["index.ts"], + format: ["cjs", "esm"], // Build for commonJS and ESmodules + dts: true, // Generate declaration file (.d.ts) + splitting: false, + sourcemap: true, + clean: true, +}); diff --git a/packages/types/README.md b/packages/types/README.md new file mode 100644 index 00000000..95d6a0cf --- /dev/null +++ b/packages/types/README.md @@ -0,0 +1,15 @@ +# types + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.0.4. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/packages/types/index.ts b/packages/types/index.ts new file mode 100644 index 00000000..00f756e1 --- /dev/null +++ b/packages/types/index.ts @@ -0,0 +1,74 @@ +export type MixanJson = Record + +export type EventPayload = { + name: string + time: string + externalId: string | null + properties: MixanJson +} + +export type ProfilePayload = { + first_name?: string + last_name?: string + email?: string + avatar?: string + id: string + properties: MixanJson +} + +export type ProfileIncrementPayload = { + name: string + value: number + id: string +} + +export type ProfileDecrementPayload = { + name: string + value: number + id: string +} + +// Batching +export type BatchEvent = { + type: 'event', + payload: EventPayload +} + +export type BatchProfile = { + type: 'profile', + payload: ProfilePayload +} + +export type BatchProfileIncrement = { + type: 'profile_increment', + payload: ProfileIncrementPayload +} + +export type BatchProfileDecrement = { + type: 'profile_decrement', + payload: ProfileDecrementPayload +} + +export type BatchItem = BatchEvent | BatchProfile | BatchProfileIncrement | BatchProfileDecrement +export type BatchPayload = Array + + +export type MixanIssue = { + field: string + message: string + value: any +} + +export type MixanIssuesResponse = { + issues: Array, +} + +export type MixanErrorResponse = { + code: string + message: string +} + +export type MixanResponse = { + result: T + status: 'ok' +} \ No newline at end of file diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 00000000..0ebdfd65 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,12 @@ +{ + "name": "@mixan/types", + "version": "0.0.1", + "type": "module", + "module": "index.ts", + "devDependencies": { + "bun-types": "latest" + }, + "dependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 00000000..7556e1d4 --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": [ + "bun-types" // add Bun global + ] + } +} diff --git a/packages/types/tsup.config.ts b/packages/types/tsup.config.ts new file mode 100644 index 00000000..9c84804e --- /dev/null +++ b/packages/types/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["index.ts"], + format: ["cjs", "esm"], // Build for commonJS and ESmodules + dts: true, // Generate declaration file (.d.ts) + splitting: false, + sourcemap: false, + clean: true, +}); diff --git a/publish.ts b/publish.ts new file mode 100644 index 00000000..a2106358 --- /dev/null +++ b/publish.ts @@ -0,0 +1,64 @@ +import sdkPkg from './packages/sdk/package.json' +import typesPkg from './packages/types/package.json' +import fs from 'node:fs' +import {execSync} from 'node:child_process' +import semver from 'semver' + +function savePackageJson(path: string, data: Record) { + fs.writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8') +} + +function main() { + const [version] = process.argv.slice(2) + + if(!version) { + return console.error('Missing version') + } + + if(!semver.valid(version)) { + return console.error('Version is not valid') + } + + const properties = { + private: false, + version, + type: 'module', + main: './dist/index.js', + module: './dist/index.mjs', + types: './dist/index.d.ts', + files: ['dist'], + } + + savePackageJson('./packages/sdk/package.json', { + ...sdkPkg, + ...properties, + dependencies: Object.entries(sdkPkg.dependencies).reduce( + (acc, [depName, depVersion]) => ({ + ...acc, + [depName]: depName.startsWith('@mixan') ? version : depVersion, + }), + {} + ), + }) + + savePackageJson('./packages/types/package.json', { + ...typesPkg, + ...properties, + }) + + execSync('bunx tsup', { + cwd: './packages/sdk', + }) + execSync('npm publish --access=public', { + cwd: './packages/sdk', + }) + + execSync('bunx tsup', { + cwd: './packages/types', + }) + execSync('npm publish --access=public', { + cwd: './packages/types', + }) +} + +main()