wip: docker

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-01-14 07:39:02 +01:00
parent 1b10371940
commit 719a82f1c4
68 changed files with 3105 additions and 328 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Ready for docker-compose
REDIS_URL="redis://127.0.0.1:6379"
DATABASE_URL="postgres://username:password@127.0.0.1:5435/postgres?sslmode=disable"
NEXTAUTH_SECRET="secret_sauce"
NEXTAUTH_URL="http://localhost:3000"

View File

@@ -69,39 +69,35 @@ For pushing events
### Usage
```ts
import { Mixan } from '@mixan/sdk';
import { MixanWeb } from '@mixan/sdk-web';
const mixan = new Mixan({
// import { MixanNative } from '@mixan/sdk-native';
const mixan = new MixanWeb({
clientId: 'uuid',
clientSecret: 'uuid',
url: 'http://localhost:8080/api/sdk',
batchInterval: 10000,
verbose: false,
saveProfileId(id) {
// Web
localStorage.setItem('@profileId', id);
// // react-native-mmkv
// mmkv.setItem('@profileId', id)
},
removeProfileId() {
// Web
localStorage.removeItem('@profileId');
// // react-native-mmkv
// mmkv.delete('@profileId')
},
getProfileId() {
// Web
return localStorage.getItem('@profileId');
// // react-native-mmkv
// return mmkv.getString('@profileId')
},
trackIp: true,
});
// const mixan = new MixanNative({
// clientId: 'uuid',
// clientSecret: 'uuid',
// url: 'http://localhost:8080/api/sdk',
// batchInterval: 10000,
// verbose: false,
// trackIp: true,
// });
// Call this before you send any events
// It will create a anonymous profile
// This profile will be merged if you call `setUser` in a later stage
mixan.init();
// tracks all outgoing links as a `link_out` event
mixan.trackOutgoingLinks();
mixan.setUser({
id: 'id',
first_name: 'John',
@@ -125,10 +121,13 @@ 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
// Screen view for web
mixan.screenView();
// Screen view for native
mixan.screenView('Article', {
id: '3',
title: 'Nice article here',
});
// Call this when a user is logged out.
@@ -150,6 +149,11 @@ We use https://cron-job.org (free) to handle our cronjobs, you can use any provi
- **https://domain.com/api/cron/cache/update** Will update the memory cache
- **https://domain.com/api/cron/events/enrich** Enrich events (adds duration etc)
## Development
1. Run `docker-compose up -d` to get redis and postgres running
2. Then `pnpm dev` to boot the web and worker (queue)
## Screenshots
![Line chart](images/line.png)

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3002",
"build": "next build",
"start": "next start",
"lint": "eslint .",

View File

@@ -2,10 +2,13 @@ import { MixanWeb } from '@mixan-test/sdk-web';
export const mixan = new MixanWeb({
verbose: true,
url: process.env.NEXT_PUBLIC_MIXAN_URL!,
clientId: process.env.NEXT_PUBLIC_MIXAN_CLIENT_ID!,
clientSecret: process.env.NEXT_PUBLIC_MIXAN_CLIENT_SECRET!,
url: 'http://localhost:3000/api/sdk',
clientId: '568b4ed1-5d00-4f27-88a7-b8959e6674bd',
clientSecret: '1e362905-d352-44c4-9263-e037a2ad52fb',
trackIp: true,
});
mixan.init({
appVersion: '1.0.0',
});
mixan.trackOutgoingLinks();

View File

@@ -3,11 +3,10 @@ import { mixan } from '@/analytics';
import type { AppProps } from 'next/app';
import { useRouter } from 'next/router';
mixan.init();
export default function MyApp({ Component, pageProps }: AppProps) {
const router = useRouter();
useEffect(() => {
mixan.screenView();
return router.events.on('routeChangeComplete', () => {
mixan.screenView();
});

View File

@@ -1,25 +0,0 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DATABASE_URL="file:./db.sqlite"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
# NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
# Next Auth Discord Provider
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""

View File

@@ -7,9 +7,13 @@ await import('./src/env.mjs');
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: true,
transpilePackages: [],
transpilePackages: ['@mixan/queue'],
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
experimental: {
// Avoid "Critical dependency: the request of a dependency is an expression"
serverComponentsExternalPackages: ['bullmq'],
},
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*

View File

@@ -3,19 +3,19 @@
"version": "0.1.0",
"private": true,
"scripts": {
"postinstall": "prisma generate",
"dev": "next dev",
"dev": "pnpm with-env next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"format": "prettier --write \"**/*.{tsx,mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env -c --"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@mixan/db": "workspace:^",
"@mixan/queue": "workspace:^",
"@mixan/types": "workspace:*",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^5.1.1",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-avatar": "^1.0.4",
@@ -83,7 +83,6 @@
"postcss": "^8.4.27",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.1",
"prisma": "^5.1.1",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2"
},

View File

@@ -1,7 +1,8 @@
import { formatDate } from '@/utils/date';
import type { Project as IProject } from '@prisma/client';
import type { ColumnDef } from '@tanstack/react-table';
import type { Project as IProject } from '@mixan/db';
import { ProjectActions } from './ProjectActions';
export type Project = IProject;

View File

@@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { toast } from '@/components/ui/use-toast';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useRefetchActive } from '@/hooks/useRefetchActive';
@@ -144,8 +143,12 @@ export default function CreateProject() {
name="withCors"
control={control}
render={({ field }) => (
<label className="flex items-center gap-2 text-sm font-medium leading-none mb-4">
<label
htmlFor="cors"
className="flex items-center gap-2 text-sm font-medium leading-none mb-4"
>
<Checkbox
id="cors"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}

View File

@@ -1,11 +1,8 @@
import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions';
import { tickProfileProperty } from '@/server/services/profile.service';
import { Prisma } from '@prisma/client';
import type { NextApiRequest, NextApiResponse } from 'next';
import { mergeDeepRight } from 'ramda';
import { eventsQueue } from '@mixan/queue';
import type { BatchPayload } from '@mixan/types';
interface Request extends NextApiRequest {
@@ -28,207 +25,13 @@ export default async function handler(req: Request, res: NextApiResponse) {
return handleError(res, createError(405, 'Method not allowed'));
}
const time = Date.now();
try {
// Check client id & secret
const projectId = await validateSdkRequest(req, res);
const profileIds = new Set<string>(
req.body
.map((item) => item.payload.profileId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
);
if (profileIds.size === 0) {
return res.status(400).json({ status: 'error' });
}
const profiles = await db.profile.findMany({
where: {
id: {
in: Array.from(profileIds),
},
},
});
// eslint-disable-next-line no-inner-declarations
async function getProfile(profileId: string) {
const profile = profiles.find((profile) => profile.id === profileId);
if (profile) {
return profile;
}
const created = await db.profile.create({
data: {
id: profileId,
properties: {},
project_id: projectId,
},
});
profiles.push(created);
return created;
}
const mergedBody: BatchPayload[] = req.body.reduce((acc, item) => {
const canMerge =
item.type === 'update_profile' || item.type === 'update_session';
if (!canMerge) {
return [...acc, item];
}
const match = acc.findIndex(
(i) =>
i.type === item.type && i.payload.profileId === item.payload.profileId
);
if (acc[match]) {
acc[match]!.payload = mergeDeepRight(acc[match]!.payload, item.payload);
} else {
acc.push(item);
}
return acc;
}, [] as BatchPayload[]);
const failedEvents: BatchPayload[] = [];
for (const item of mergedBody) {
try {
const { type, payload } = item;
const profile = await getProfile(payload.profileId);
switch (type) {
case 'create_profile': {
profile.properties = {
...(typeof profile.properties === 'object'
? profile.properties ?? {}
: {}),
...(payload.properties ?? {}),
};
await db.profile.update({
where: {
id: payload.profileId,
},
data: {
properties: profile.properties,
},
});
break;
}
case 'update_profile': {
profile.properties = {
...(typeof profile.properties === 'object'
? profile.properties ?? {}
: {}),
...(payload.properties ?? {}),
};
await db.profile.update({
where: {
id: payload.profileId,
},
data: {
external_id: payload.id,
email: payload.email,
first_name: payload.first_name,
last_name: payload.last_name,
avatar: payload.avatar,
properties: profile.properties,
},
});
break;
}
case 'set_profile_property': {
if (
typeof (profile.properties as Record<string, unknown>)[
payload.name
] === 'undefined'
) {
(profile.properties as Record<string, unknown>)[payload.name] =
payload.value;
await db.profile.update({
where: {
id: payload.profileId,
},
data: {
// @ts-expect-error
properties: profile.properties,
},
});
}
break;
}
case 'increment': {
await tickProfileProperty({
profileId: payload.profileId,
name: payload.name,
tick: payload.value,
});
break;
}
case 'decrement': {
await tickProfileProperty({
profileId: payload.profileId,
name: payload.name,
tick: -Math.abs(payload.value),
});
break;
}
case 'event': {
await db.event.create({
data: {
name: payload.name,
properties: payload.properties,
createdAt: payload.time,
project_id: projectId,
profile_id: payload.profileId,
},
});
break;
}
case 'update_session': {
const session = await db.event.findFirst({
where: {
profile_id: payload.profileId,
project_id: projectId,
name: 'session_start',
},
orderBy: {
createdAt: 'desc',
},
});
if (session) {
await db.$executeRawUnsafe(
`UPDATE events SET properties = '${JSON.stringify(
payload.properties
)}' || properties WHERE "createdAt" >= '${session.createdAt.toISOString()}' AND profile_id = '${
payload.profileId
}'`
);
}
break;
}
}
} catch (error) {
console.log(`Failed to create "${item.type}"`);
console.log(' > Payload:', item.payload);
console.log(' > Error:', error);
failedEvents.push(item);
}
} // end for
await db.eventFailed.createMany({
data: failedEvents.map((item) => ({
data: item as Record<string, any>,
})),
});
console.log('Batch took', Date.now() - time, 'ms', {
events: req.body.length,
combined: mergedBody.length,
await eventsQueue.add('batch', {
projectId,
payload: req.body,
});
res.status(200).json({ status: 'ok' });

View File

@@ -4,14 +4,10 @@ import { createError, handleError } from '@/server/exceptions';
import type { NextApiRequest, NextApiResponse } from 'next';
import randomAnimalName from 'random-animal-name';
import type {
CreateProfilePayload,
CreateProfileResponse,
ProfilePayload,
} from '@mixan/types';
import type { CreateProfileResponse, ProfilePayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfilePayload | CreateProfilePayload;
body: ProfilePayload;
}
export default async function handler(req: Request, res: NextApiResponse) {

View File

@@ -0,0 +1,32 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { eventsQueue } from '@mixan/queue';
import type { BatchPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: BatchPayload[];
}
export const config = {
api: {
responseLimit: false,
},
};
export default function handler(req: Request, res: NextApiResponse) {
eventsQueue.add('batch', {
payload: [
{
type: 'event',
payload: {
profileId: 'f8235c6a-c720-4f38-8f6c-b6b7d31e16db',
name: 'test',
properties: {},
time: new Date().toISOString(),
},
},
],
projectId: 'b725eadb-a1fe-4be8-bf0b-9d9bfa6aac12',
});
res.status(200).json({ status: 'ok' });
}

View File

@@ -41,6 +41,7 @@ export const clientRouter = createTRPCRouter({
z.object({
id: z.string(),
name: z.string(),
cors: z.string(),
})
)
.mutation(({ input }) => {
@@ -50,6 +51,7 @@ export const clientRouter = createTRPCRouter({
},
data: {
name: input.name,
cors: input.cors,
},
});
}),

View File

@@ -3,10 +3,11 @@ import { db } from '@/server/db';
import { getDashboardBySlug } from '@/server/services/dashboard.service';
import { getProjectBySlug } from '@/server/services/project.service';
import { slug } from '@/utils/slug';
import { Prisma } from '@prisma/client';
import { PrismaError } from 'prisma-error-enum';
import { z } from 'zod';
import { Prisma } from '@mixan/db';
export const dashboardRouter = createTRPCRouter({
get: protectedProcedure
.input(

View File

@@ -11,9 +11,10 @@ import type {
} from '@/types';
import { alphabetIds, timeRanges } from '@/utils/constants';
import { zChartInput } from '@/utils/validation';
import type { Report as DbReport } from '@prisma/client';
import { z } from 'zod';
import type { Report as DbReport } from '@mixan/db';
function transformFilter(
filter: Partial<IChartEventFilter>,
index: number
@@ -48,7 +49,7 @@ function transformReport(report: DbReport): IChartInput & { id: string } {
chartType: report.chart_type,
interval: report.interval,
name: report.name || 'Untitled',
range: report.range as IChartRange ?? timeRanges['1m'],
range: (report.range as IChartRange) ?? timeRanges['1m'],
};
}

View File

@@ -128,7 +128,16 @@ export async function validateSdkRequest(
throw createError(401, 'Invalid client secret');
}
} else if (client.cors !== '*') {
res.setHeader('Access-Control-Allow-Origin', client.cors);
const ok = client.cors.split(',').find((origin) => {
if (origin === req.headers.origin) {
return true;
}
});
if (ok) {
res.setHeader('Access-Control-Allow-Origin', String(req.headers.origin));
} else {
throw createError(401, 'Invalid cors settings');
}
}
return client.project_id;

View File

@@ -1,14 +1 @@
import { env } from '@/env.mjs';
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['error'],
});
if (env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
export { db } from '@mixan/db';

View File

@@ -8,10 +8,11 @@ import type {
zChartType,
zTimeInterval,
} from '@/utils/validation';
import type { Client, Project } from '@prisma/client';
import type { TooltipProps } from 'recharts';
import type { z } from 'zod';
import type { Client, Project } from '@mixan/db';
export type HtmlProps<T> = Omit<
React.DetailedHTMLProps<React.HTMLAttributes<T>, T>,
'ref'

View File

@@ -1,4 +1,4 @@
import type { Profile } from '@prisma/client';
import type { Profile } from '@mixan/db';
export function getProfileName(profile: Profile | undefined | null) {
if (!profile) return '';

40
apps/worker/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "@mixan/worker",
"version": "0.0.1",
"scripts": {
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
"start": "node dist/index.js",
"build": "rm -rf dist && tsup",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bull-board/api": "^5.13.0",
"@bull-board/express": "^5.13.0",
"@mixan/db": "workspace:*",
"@mixan/queue": "workspace:*",
"bullmq": "^5.1.1",
"express": "^4.18.2",
"ramda": "^0.29.1"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"@mixan/types": "workspace:*",
"@types/express": "^4.17.21",
"@types/ramda": "^0.29.6",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"tsup": "^7.2.0",
"typescript": "^5.2.2"
},
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

29
apps/worker/src/index.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { Worker } from 'bullmq';
import express from 'express';
import { connection, eventsQueue } from '@mixan/queue';
import { eventsJob } from './jobs/events';
const PORT = process.env.PORT || 3001;
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/');
const app = express();
new Worker(eventsQueue.name, eventsJob, {
connection,
});
createBullBoard({
queues: [new BullMQAdapter(eventsQueue)],
serverAdapter: serverAdapter,
});
app.use('/', serverAdapter.getRouter());
app.listen(PORT, () => {
console.log(`For the UI, open http://localhost:${PORT}/`);
});

View File

@@ -0,0 +1,250 @@
import type { Job } from 'bullmq';
import { mergeDeepRight } from 'ramda';
import { db } from '@mixan/db';
import type { EventsQueuePayload } from '@mixan/queue/src/queues';
import type { BatchPayload } from '@mixan/types';
export async function eventsJob(job: Job<EventsQueuePayload>) {
const projectId = job.data.projectId;
const body = job.data.payload;
const profileIds = new Set<string>(
body
.map((item) => item.payload.profileId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
);
if (profileIds.size === 0) {
return null;
}
const profiles = await db.profile.findMany({
where: {
id: {
in: Array.from(profileIds),
},
},
});
async function getProfile(profileId: string) {
const profile = profiles.find((profile) => profile.id === profileId);
if (profile) {
return profile;
}
const created = await db.profile.create({
data: {
id: profileId,
properties: {},
project_id: projectId,
},
});
profiles.push(created);
return created;
}
const mergedBody: BatchPayload[] = body.reduce((acc, item) => {
const canMerge =
item.type === 'update_profile' || item.type === 'update_session';
if (!canMerge) {
return [...acc, item];
}
const match = acc.findIndex(
(i) =>
i.type === item.type && i.payload.profileId === item.payload.profileId
);
if (acc[match]) {
acc[match]!.payload = mergeDeepRight(acc[match]!.payload, item.payload);
} else {
acc.push(item);
}
return acc;
}, [] as BatchPayload[]);
const failedEvents: BatchPayload[] = [];
for (const item of mergedBody) {
try {
const { type, payload } = item;
const profile = await getProfile(payload.profileId);
switch (type) {
case 'create_profile': {
profile.properties = {
...(typeof profile.properties === 'object'
? profile.properties ?? {}
: {}),
...(payload.properties ?? {}),
};
await db.profile.update({
where: {
id: payload.profileId,
},
data: {
properties: profile.properties,
},
});
break;
}
case 'update_profile': {
profile.properties = {
...(typeof profile.properties === 'object'
? profile.properties ?? {}
: {}),
...(payload.properties ?? {}),
};
await db.profile.update({
where: {
id: payload.profileId,
},
data: {
external_id: payload.id,
email: payload.email,
first_name: payload.first_name,
last_name: payload.last_name,
avatar: payload.avatar,
properties: profile.properties,
},
});
break;
}
case 'set_profile_property': {
if (
typeof (profile.properties as Record<string, unknown>)[
payload.name
] === 'undefined'
) {
(profile.properties as Record<string, unknown>)[payload.name] =
payload.value;
await db.profile.update({
where: {
id: payload.profileId,
},
data: {
// @ts-expect-error
properties: profile.properties,
},
});
}
break;
}
case 'increment': {
await tickProfileProperty({
profileId: payload.profileId,
name: payload.name,
tick: payload.value,
});
break;
}
case 'decrement': {
await tickProfileProperty({
profileId: payload.profileId,
name: payload.name,
tick: -Math.abs(payload.value),
});
break;
}
case 'event': {
await db.event.create({
data: {
name: payload.name,
properties: payload.properties,
createdAt: payload.time,
project_id: projectId,
profile_id: payload.profileId,
},
});
break;
}
case 'update_session': {
const session = await db.event.findFirst({
where: {
profile_id: payload.profileId,
project_id: projectId,
name: 'session_start',
},
orderBy: {
createdAt: 'desc',
},
});
if (session) {
await db.$executeRawUnsafe(
`UPDATE events SET properties = '${JSON.stringify(
payload.properties
)}' || properties WHERE "createdAt" >= '${session.createdAt.toISOString()}' AND profile_id = '${
payload.profileId
}'`
);
}
break;
}
}
} catch (error) {
job.log(`Failed to create "${item.type}"`);
job.log(` > Payload: ${JSON.stringify(item.payload)}`);
if (error instanceof Error) {
job.log(` > Error: ${error.message.trim()}`);
job.log(` > Stack: ${error.stack}`);
}
failedEvents.push(item);
job.log(`---`);
}
} // end for
await db.eventFailed.createMany({
data: failedEvents.map((item) => ({
data: item as Record<string, any>,
})),
});
return body;
}
export async function tickProfileProperty({
profileId,
tick,
name,
}: {
profileId: string;
tick: number;
name: string;
}) {
const profile = await db.profile.findUniqueOrThrow({
where: {
id: 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') {
return `Property "${name}" on user is of type ${typeof value}`;
}
if (typeof tick !== 'number') {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
return `Value is not a number ${tick} (${typeof tick})`;
}
await db.profile.update({
where: {
id: profileId,
},
data: {
properties: {
...properties,
[name]: value + tick,
},
},
});
}

12
apps/worker/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "@mixan/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'tsup';
import type { Options } from 'tsup';
const options: Options = {
clean: true,
entry: ['src/index.ts'],
noExternal: [/^@mixan\/.*$/u, /^@\/.*$/u],
sourcemap: true,
splitting: false,
};
if (process.env.WATCH) {
options.watch = ['src/**/*', '../../packages/**/*'];
options.onSuccess = 'node dist/index.js';
}
export default defineConfig(options);

View File

@@ -1,10 +0,0 @@
#!/bin/bash
docker build \
--build-arg DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public" \
--build-arg NEXTAUTH_SECRET="secret_sauce" \
--build-arg NEXTAUTH_URL="http://localhost:3000" \
-t mixan/app:latest \
-t mixan/app:1.0 \
-f apps/web/Dockerfile \
.

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:alpine
environment:
POSTGRES_DB: postgres
POSTGRES_PASSWORD: password
POSTGRES_USER: username
ports:
- 5435:5432
restart: on-failure:3
pgweb:
image: sosedoff/pgweb
depends_on:
- postgres
environment:
PGWEB_DATABASE_URL: postgres://username:password@postgres:5432/postgres?sslmode=disable
ports:
- 8085:8081
restart: on-failure:3
redis:
image: redis:latest
command: redis-server
volumes:
- redis:/var/lib/redis
- redis-config:/usr/local/etc/redis/redis.conf
ports:
- 6379:6379
networks:
- redis-network
redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=local:redis:6379
- HTTP_USER=root
- HTTP_PASSWORD=qwerty
ports:
- 8081:8081
networks:
- redis-network
depends_on:
- redis
volumes:
redis:
redis-config:
networks:
redis-network:
driver: bridge

103
docker/Dockerfile-composed Normal file
View File

@@ -0,0 +1,103 @@
# Docker file that contains all the necessary services to run the app
# EXCEPT the database. The database is run in a separate container.
# Services: web, worker (queue), redis
# Excluded: db (postgres)
FROM --platform=linux/amd64 node:20-slim AS base
ARG DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public"
ENV DATABASE_URL=$DATABASE_URL
ARG NEXTAUTH_SECRET="secret_sauce"
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
ARG NEXTAUTH_URL="http://localhost:3300"
ENV NEXTAUTH_URL=$NEXTAUTH_URL
ARG REDIS_URL="redis://127.0.0.1:6379"
ENV REDIS_URL=$REDIS_URL
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
ARG NODE_VERSION=20
RUN apt update \
&& apt install -y curl supervisor redis \
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
&& bash n $NODE_VERSION \
&& rm n \
&& npm install -g n
WORKDIR /app
COPY package.json package.json
COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY apps/web/package.json apps/web/package.json
COPY apps/worker/package.json apps/worker/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/queue/package.json packages/queue/package.json
COPY packages/types/package.json packages/types/package.json
# BUILD
FROM base AS build
WORKDIR /app/apps/web
RUN pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /app/apps/worker
RUN pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /app
COPY apps apps
COPY packages packages
COPY tooling tooling
RUN pnpm db:codegen
WORKDIR /app/apps/web
RUN pnpm run build
WORKDIR /app/apps/worker
RUN pnpm run build
# PROD
FROM base AS prod
WORKDIR /app
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
# FINAL
FROM base AS runner
COPY --from=build /app/package.json /app/package.json
COPY --from=prod /app/node_modules /app/node_modules
# Apps
COPY --from=build /app/apps/web /app/apps/web
COPY --from=build /app/apps/worker /app/apps/worker
# Apps node_modules
COPY --from=prod /app/apps/web/node_modules /app/apps/web/node_modules
COPY --from=prod /app/apps/worker/node_modules /app/apps/worker/node_modules
# Packages
COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/queue /app/packages/queue
# Packages node_modules
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
RUN pnpm db:codegen
RUN mkdir -p /var/log/supervisor /data
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/redis.conf /etc/redis/redis.conf
# Redis data
VOLUME [ "/data" ]
EXPOSE 3000 3001
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

85
docker/Dockerfile-web Normal file
View File

@@ -0,0 +1,85 @@
# Dockerfile that builds the web app only
FROM --platform=linux/amd64 node:20-slim AS base
ARG DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public"
ENV DATABASE_URL=$DATABASE_URL
ARG NEXTAUTH_SECRET="secret_sauce"
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
ARG NEXTAUTH_URL="http://localhost:3300"
ENV NEXTAUTH_URL=$NEXTAUTH_URL
ARG REDIS_URL="redis://127.0.0.1:6379"
ENV REDIS_URL=$REDIS_URL
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
ARG NODE_VERSION=20
RUN apt update \
&& apt install -y curl \
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
&& bash n $NODE_VERSION \
&& rm n \
&& npm install -g n
WORKDIR /app
COPY package.json package.json
COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY apps/web/package.json apps/web/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/queue/package.json packages/queue/package.json
COPY packages/types/package.json packages/types/package.json
# BUILD
FROM base AS build
WORKDIR /app/apps/web
RUN pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /app
COPY apps apps
COPY packages packages
COPY tooling tooling
RUN pnpm db:codegen
WORKDIR /app/apps/web
RUN pnpm run build
# PROD
FROM base AS prod
WORKDIR /app/apps/web
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
# FINAL
FROM base AS runner
COPY --from=build /app/package.json /app/package.json
COPY --from=prod /app/node_modules /app/node_modules
# Apps
COPY --from=build /app/apps/web /app/apps/web
# Apps node_modules
COPY --from=prod /app/apps/web/node_modules /app/apps/web/node_modules
# Packages
COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/queue /app/packages/queue
# Packages node_modules
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
RUN pnpm db:codegen
WORKDIR /app/apps/web
EXPOSE 3000
CMD ["pnpm", "start"]

79
docker/Dockerfile-worker Normal file
View File

@@ -0,0 +1,79 @@
# Dockerfile that builds the worker only
FROM --platform=linux/amd64 node:20-slim AS base
ARG DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public"
ENV DATABASE_URL=$DATABASE_URL
ARG REDIS_URL="redis://127.0.0.1:6379"
ENV REDIS_URL=$REDIS_URL
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
ARG NODE_VERSION=20
RUN apt update \
&& apt install -y curl \
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
&& bash n $NODE_VERSION \
&& rm n \
&& npm install -g n
WORKDIR /app
COPY package.json package.json
COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY apps/worker/package.json apps/worker/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/queue/package.json packages/queue/package.json
COPY packages/types/package.json packages/types/package.json
# BUILD
FROM base AS build
WORKDIR /app/apps/worker
RUN pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /app
COPY apps apps
COPY packages packages
COPY tooling tooling
RUN pnpm db:codegen
WORKDIR /app/apps/worker
RUN pnpm run build
# PROD
FROM base AS prod
WORKDIR /app/apps/worker
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
# FINAL
FROM base AS runner
COPY --from=build /app/package.json /app/package.json
COPY --from=prod /app/node_modules /app/node_modules
# Apps
COPY --from=build /app/apps/worker /app/apps/worker
# Apps node_modules
COPY --from=prod /app/apps/worker/node_modules /app/apps/worker/node_modules
# Packages
COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/queue /app/packages/queue
# Packages node_modules
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
RUN pnpm db:codegen
WORKDIR /app/apps/worker
EXPOSE 3000
CMD ["pnpm", "start"]

11
docker/build-composed Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
docker build \
--build-arg DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public" \
--build-arg NEXTAUTH_SECRET="secret_sauce" \
--build-arg NEXTAUTH_URL="http://localhost:3300" \
--build-arg REDIS_URL="redis://127.0.0.1:6379" \
-t mixan/composed:latest \
-t mixan/composed:1.0 \
-f docker/Dockerfile-composed \
.

1329
docker/redis.conf Normal file

File diff suppressed because it is too large Load Diff

34
docker/supervisord.conf Normal file
View File

@@ -0,0 +1,34 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
[include]
files = /etc/supervisor/conf.d/*.conf
[program:web]
directory=/app/apps/web
command=pnpm start
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
redirect_stderr=true
[program:worker]
directory=/app/apps/worker
command=pnpm start
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
redirect_stderr=true
[program:redis]
command=redis-server /etc/redis/redis.conf
autostart=true
autorestart=true
user=root
stdout_logfile=/var/log/redis/stdout.log
stderr_logfile=/var/log/redis/stderr.log

View File

@@ -7,6 +7,9 @@
"packageManager": "pnpm@8.7.6",
"module": "index.ts",
"scripts": {
"db:codegen": "pnpm -r --filter db run codegen",
"migrate": "pnpm -r --filter db run migrate",
"migrate:deploy": "pnpm -r --filter db run migrate:deploy",
"dev": "pnpm -r dev",
"format": "pnpm -r format --cache --cache-location=\"node_modules/.cache/.prettiercache\"",
"format:fix": "pnpm -r format --write --cache --cache-location=\"node_modules/.cache/.prettiercache\"",
@@ -16,11 +19,10 @@
"typecheck": "pnpm -r typecheck"
},
"dependencies": {
"dotenv-cli": "^7.3.0",
"@mixan/prettier-config": "^0.1.0",
"prettier": "^3.0.3",
"typescript": "^5.2.2"
},
"devDependencies": {
"typescript": "^5.2.2",
"semver": "^7.5.4"
},
"prettier": "@mixan/prettier-config"

15
packages/db/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
export * from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

35
packages/db/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@mixan/db",
"version": "0.0.1",
"main": "index.ts",
"scripts": {
"codegen": "pnpm with-env prisma generate",
"migrate": "pnpm with-env prisma migrate dev",
"migrate:deploy": "pnpm with-env prisma migrate deploy",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env -c --"
},
"dependencies": {
"@prisma/client": "^5.1.1"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"@mixan/types": "workspace:*",
"@types/node": "^18.16.0",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"prisma": "^5.1.1",
"typescript": "^5.2.2"
},
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

12
packages/db/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "@mixan/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

2
packages/queue/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { eventsQueue } from './src/queues';
export { connection } from './src/connection';

View File

@@ -0,0 +1,30 @@
{
"name": "@mixan/queue",
"version": "0.0.1",
"main": "index.ts",
"scripts": {
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"bullmq": "^5.1.1"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"@mixan/types": "workspace:*",
"@types/node": "^18.16.0",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"typescript": "^5.2.2"
},
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

View File

@@ -0,0 +1,10 @@
const parse = (connectionString: string) => {
const url = new URL(connectionString);
return {
host: url.hostname,
port: Number(url.port),
password: url.password,
} as const;
};
export const connection = parse(String(process.env.REDIS_URL));

View File

@@ -0,0 +1,17 @@
import { Queue } from 'bullmq';
import type { BatchPayload } from '@mixan/types';
import { connection } from './connection';
export interface EventsQueuePayload {
projectId: string;
payload: BatchPayload[];
}
export const eventsQueue = new Queue<EventsQueuePayload>('events', {
connection,
defaultJobOptions: {
removeOnComplete: 10,
},
});

View File

@@ -0,0 +1,12 @@
{
"extends": "@mixan/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -48,7 +48,7 @@ export function getDevice() {
ua
);
const t2 =
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test(
ua.slice(0, 4)
);
if (t1 || t2) {

831
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff