added tooling (eslint, typescript and prettier)

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-11-02 12:14:37 +01:00
parent 575b3c23bf
commit 493e1b7650
82 changed files with 1890 additions and 1363 deletions

1
.gitignore vendored
View File

@@ -176,3 +176,4 @@ dist
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
*.tsbuildinfo

7
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors"
]
}

23
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"editor.codeActionsOnSave": { "source.fixAll.eslint": true },
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[handlebars]": {
"editor.formatOnSave": false,
"editor.codeActionsOnSave": { "source.fixAll.eslint": false }
},
"files.associations": { "*.hbs": "handlebars" },
"eslint.workingDirectories": [
{ "pattern": "apps/*/" },
{ "pattern": "packages/*/" },
{ "pattern": "tooling/*/" }
],
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.tsdk": "node_modules/typescript/lib",
"[yaml]": {
"editor.insertSpaces": true,
"editor.tabSize": 2,
"editor.autoIndent": "advanced"
},
"editor.inlineSuggest.enabled": true
}

View File

@@ -13,4 +13,4 @@
"components": "@/components", "components": "@/components",
"utils": "@/utils/cn" "utils": "@/utils/cn"
} }
} }

View File

@@ -2,7 +2,7 @@
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds. * for Docker builds.
*/ */
await import("./src/env.mjs"); await import('./src/env.mjs');
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
@@ -14,8 +14,8 @@ const config = {
* @see https://github.com/vercel/next.js/issues/41980 * @see https://github.com/vercel/next.js/issues/41980
*/ */
i18n: { i18n: {
locales: ["en"], locales: ['en'],
defaultLocale: "en", defaultLocale: 'en',
}, },
}; };

View File

@@ -3,13 +3,13 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "next build",
"db:push": "prisma db push",
"dev": "next dev",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"lint": "next lint", "dev": "next dev",
"typecheck": "tsc --noEmit", "build": "next build",
"start": "next start" "start": "next start",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.2", "@hookform/resolvers": "^3.3.2",
@@ -60,26 +60,32 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/eslint": "^8.44.2",
"@types/node": "^18.16.0", "@types/node": "^18.16.0",
"@types/ramda": "^0.29.6", "@types/ramda": "^0.29.6",
"@types/react": "^18.2.20", "@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/react-syntax-highlighter": "^15.5.9", "@types/react-syntax-highlighter": "^15.5.9",
"@typescript-eslint/eslint-plugin": "^6.3.0",
"@typescript-eslint/parser": "^6.3.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"eslint": "^8.47.0", "eslint": "^8.48.0",
"eslint-config-next": "^13.5.4", "eslint-config-next": "^13.5.4",
"postcss": "^8.4.27", "postcss": "^8.4.27",
"prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.5.1", "prettier-plugin-tailwindcss": "^0.5.1",
"prisma": "^5.1.1", "prisma": "^5.1.1",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "^5.1.6" "typescript": "^5.2.0"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.21.0" "initVersion": "7.21.0"
} },
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base",
"@mixan/eslint-config/react"
]
},
"prettier": "@mixan/prettier-config"
} }

View File

@@ -1,6 +1,6 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').options} */ /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').options} */
const config = { const config = {
plugins: ["prettier-plugin-tailwindcss"], plugins: ['prettier-plugin-tailwindcss'],
}; };
export default config; export default config;

View File

@@ -1,3 +1,3 @@
export { default } from "next-auth/middleware" export { default } from 'next-auth/middleware';
export const config = { matcher: ["/dashboard"] } export const config = { matcher: ['/dashboard'] };

View File

@@ -1,9 +1,9 @@
import { useQueryParams } from "@/hooks/useQueryParams"; import { useQueryParams } from '@/hooks/useQueryParams';
import { z } from "zod"; import { z } from 'zod';
export const useReportId = () => export const useReportId = () =>
useQueryParams( useQueryParams(
z.object({ z.object({
reportId: z.string().optional(), reportId: z.string().optional(),
}), })
); );

View File

@@ -1,24 +1,24 @@
import { import {
type IChartInput,
type IChartBreakdown, type IChartBreakdown,
type IChartEvent, type IChartEvent,
type IInterval, type IChartInput,
type IChartType,
type IChartRange, type IChartRange,
} from "@/types"; type IChartType,
import { alphabetIds } from "@/utils/constants"; type IInterval,
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"; } from '@/types';
import { alphabetIds } from '@/utils/constants';
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type InitialState = IChartInput & { type InitialState = IChartInput & {
startDate: string | null; startDate: string | null;
endDate: string | null; endDate: string | null;
} };
// First approach: define the initial state using that type // First approach: define the initial state using that type
const initialState: InitialState = { const initialState: InitialState = {
name: "screen_view", name: 'screen_view',
chartType: "linear", chartType: 'linear',
interval: "day", interval: 'day',
breakdowns: [], breakdowns: [],
events: [], events: [],
range: 30, range: 30,
@@ -27,21 +27,21 @@ const initialState: InitialState = {
}; };
export const reportSlice = createSlice({ export const reportSlice = createSlice({
name: "counter", name: 'counter',
initialState, initialState,
reducers: { reducers: {
reset() { reset() {
return initialState return initialState;
}, },
setReport(state, action: PayloadAction<IChartInput>) { setReport(state, action: PayloadAction<IChartInput>) {
return { return {
...action.payload, ...action.payload,
startDate: null, startDate: null,
endDate: null, endDate: null,
} };
}, },
// Events // Events
addEvent: (state, action: PayloadAction<Omit<IChartEvent, "id">>) => { addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.events.push({ state.events.push({
id: alphabetIds[state.events.length]!, id: alphabetIds[state.events.length]!,
...action.payload, ...action.payload,
@@ -51,10 +51,10 @@ export const reportSlice = createSlice({
state, state,
action: PayloadAction<{ action: PayloadAction<{
id: string; id: string;
}>, }>
) => { ) => {
state.events = state.events.filter( state.events = state.events.filter(
(event) => event.id !== action.payload.id, (event) => event.id !== action.payload.id
); );
}, },
changeEvent: (state, action: PayloadAction<IChartEvent>) => { changeEvent: (state, action: PayloadAction<IChartEvent>) => {
@@ -69,7 +69,7 @@ export const reportSlice = createSlice({
// Breakdowns // Breakdowns
addBreakdown: ( addBreakdown: (
state, state,
action: PayloadAction<Omit<IChartBreakdown, "id">>, action: PayloadAction<Omit<IChartBreakdown, 'id'>>
) => { ) => {
state.breakdowns.push({ state.breakdowns.push({
id: alphabetIds[state.breakdowns.length]!, id: alphabetIds[state.breakdowns.length]!,
@@ -80,10 +80,10 @@ export const reportSlice = createSlice({
state, state,
action: PayloadAction<{ action: PayloadAction<{
id: string; id: string;
}>, }>
) => { ) => {
state.breakdowns = state.breakdowns.filter( state.breakdowns = state.breakdowns.filter(
(event) => event.id !== action.payload.id, (event) => event.id !== action.payload.id
); );
}, },
changeBreakdown: (state, action: PayloadAction<IChartBreakdown>) => { changeBreakdown: (state, action: PayloadAction<IChartBreakdown>) => {
@@ -99,7 +99,7 @@ export const reportSlice = createSlice({
changeInterval: (state, action: PayloadAction<IInterval>) => { changeInterval: (state, action: PayloadAction<IInterval>) => {
state.interval = action.payload; state.interval = action.payload;
}, },
// Chart type // Chart type
changeChartType: (state, action: PayloadAction<IChartType>) => { changeChartType: (state, action: PayloadAction<IChartType>) => {
state.chartType = action.payload; state.chartType = action.payload;
@@ -116,15 +116,15 @@ export const reportSlice = createSlice({
}, },
changeDateRanges: (state, action: PayloadAction<IChartRange>) => { changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.range = action.payload state.range = action.payload;
if (action.payload === 0.3 || action.payload === 0.6) { if (action.payload === 0.3 || action.payload === 0.6) {
state.interval = "minute"; state.interval = 'minute';
} else if (action.payload === 0 || action.payload === 1) { } else if (action.payload === 0 || action.payload === 1) {
state.interval = "hour"; state.interval = 'hour';
} else if (action.payload <= 30) { } else if (action.payload <= 30) {
state.interval = "day"; state.interval = 'day';
} else { } else {
state.interval = "month"; state.interval = 'month';
} }
}, },
}, },

View File

@@ -1,104 +1,100 @@
// Inspired by react-hot-toast library // Inspired by react-hot-toast library
import * as React from "react" import * as React from 'react';
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
import type { const TOAST_LIMIT = 1;
ToastActionElement, const TOAST_REMOVE_DELAY = 1000000;
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
} };
const actionTypes = { const actionTypes = {
ADD_TOAST: "ADD_TOAST", ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: "UPDATE_TOAST", UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: "DISMISS_TOAST", DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: "REMOVE_TOAST", REMOVE_TOAST: 'REMOVE_TOAST',
} as const } as const;
let count = 0 let count = 0;
function genId() { function genId() {
count = (count + 1) % Number.MAX_VALUE count = (count + 1) % Number.MAX_VALUE;
return count.toString() return count.toString();
} }
type ActionType = typeof actionTypes type ActionType = typeof actionTypes;
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"] type: ActionType['ADD_TOAST'];
toast: ToasterToast toast: ToasterToast;
} }
| { | {
type: ActionType["UPDATE_TOAST"] type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast> toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType["DISMISS_TOAST"] type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast["id"] toastId?: ToasterToast['id'];
} }
| { | {
type: ActionType["REMOVE_TOAST"] type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast["id"] toastId?: ToasterToast['id'];
} };
interface State { interface State {
toasts: ToasterToast[] toasts: ToasterToast[];
} }
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => { const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: "REMOVE_TOAST", type: 'REMOVE_TOAST',
toastId: toastId, toastId: toastId,
}) });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case "ADD_TOAST": case 'ADD_TOAST':
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
case "UPDATE_TOAST": case 'UPDATE_TOAST':
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t t.id === action.toast.id ? { ...t, ...action.toast } : t
), ),
} };
case "DISMISS_TOAST": { case 'DISMISS_TOAST': {
const { toastId } = action const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
@@ -111,82 +107,82 @@ export const reducer = (state: State, action: Action): State => {
} }
: t : t
), ),
} };
} }
case "REMOVE_TOAST": case 'REMOVE_TOAST':
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: [],
} };
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter((t) => t.id !== action.toastId),
} };
} }
} };
const listeners: Array<(state: State) => void> = [] const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] } let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach((listener) => {
listener(memoryState) listener(memoryState);
}) });
} }
type Toast = Omit<ToasterToast, "id"> type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId() const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: "UPDATE_TOAST", type: 'UPDATE_TOAST',
toast: { ...props, id }, toast: { ...props, id },
}) });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({ dispatch({
type: "ADD_TOAST", type: 'ADD_TOAST',
toast: { toast: {
...props, ...props,
id, id,
open: true, open: true,
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss() if (!open) dismiss();
}, },
}, },
}) });
return { return {
id: id, id: id,
dismiss, dismiss,
update, update,
} };
} }
function useToast() { function useToast() {
const [state, setState] = React.useState<State>(memoryState) const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.push(setState) listeners.push(setState);
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1);
} }
} };
}, [state]) }, [state]);
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
} };
} }
export { useToast, toast } export { useToast, toast };

View File

@@ -1,5 +1,5 @@
import { createEnv } from "@t3-oss/env-nextjs"; import { createEnv } from '@t3-oss/env-nextjs';
import { z } from "zod"; import { z } from 'zod';
export const env = createEnv({ export const env = createEnv({
/** /**
@@ -11,14 +11,14 @@ export const env = createEnv({
.string() .string()
.url() .url()
.refine( .refine(
(str) => !str.includes("YOUR_MYSQL_URL_HERE"), (str) => !str.includes('YOUR_MYSQL_URL_HERE'),
"You forgot to change the default URL" 'You forgot to change the default URL'
), ),
NODE_ENV: z NODE_ENV: z
.enum(["development", "test", "production"]) .enum(['development', 'test', 'production'])
.default("development"), .default('development'),
NEXTAUTH_SECRET: NEXTAUTH_SECRET:
process.env.NODE_ENV === "production" process.env.NODE_ENV === 'production'
? z.string() ? z.string()
: z.string().optional(), : z.string().optional(),
NEXTAUTH_URL: z.preprocess( NEXTAUTH_URL: z.preprocess(

View File

@@ -1,27 +1,32 @@
import { type IInterval } from "@/types"; import { type IInterval } from '@/types';
export function formatDateInterval(interval: IInterval, date: Date): string { export function formatDateInterval(interval: IInterval, date: Date): string {
if (interval === "hour" || interval === "minute") { if (interval === 'hour' || interval === 'minute') {
return new Intl.DateTimeFormat("en-GB", { return new Intl.DateTimeFormat('en-GB', {
hour: "2-digit", hour: '2-digit',
minute: "2-digit", minute: '2-digit',
}).format(date); }).format(date);
} }
if (interval === "month") { if (interval === 'month') {
return new Intl.DateTimeFormat("en-GB", { month: "short" }).format(date); return new Intl.DateTimeFormat('en-GB', { month: 'short' }).format(date);
} }
if (interval === "day") { if (interval === 'day') {
return new Intl.DateTimeFormat("en-GB", { weekday: "short", day: '2-digit', month: '2-digit' }).format( return new Intl.DateTimeFormat('en-GB', {
date, weekday: 'short',
); day: '2-digit',
month: '2-digit',
}).format(date);
} }
return date.toISOString(); return date.toISOString();
} }
export function useFormatDateInterval(interval: IInterval) { export function useFormatDateInterval(interval: IInterval) {
return (date: Date | string) => formatDateInterval(interval, typeof date === "string" ? new Date(date) : date); return (date: Date | string) =>
} formatDateInterval(
interval,
typeof date === 'string' ? new Date(date) : date
);
}

View File

@@ -1,7 +1,7 @@
import mappings from '@/mappings.json' import mappings from '@/mappings.json';
export function useMappings() { export function useMappings() {
return (val: string) => { return (val: string) => {
return mappings.find((item) => item.id === val)?.name ?? val return mappings.find((item) => item.id === val)?.name ?? val;
} };
} }

View File

@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from 'zod';
import { useQueryParams } from "./useQueryParams";
import { useQueryParams } from './useQueryParams';
export function useOrganizationParams() { export function useOrganizationParams() {
return useQueryParams( return useQueryParams(
@@ -8,6 +9,6 @@ export function useOrganizationParams() {
project: z.string(), project: z.string(),
dashboard: z.string(), dashboard: z.string(),
profileId: z.string().optional(), profileId: z.string().optional(),
}), })
); );
} }

View File

@@ -1,7 +1,6 @@
import { useMemo } from "react"; import { useMemo } from 'react';
import { useRouter } from 'next/router';
import { useRouter } from "next/router"; import { type z } from 'zod';
import { type z } from "zod";
export function useQueryParams<Z extends z.ZodTypeAny = z.ZodNever>(zod: Z) { export function useQueryParams<Z extends z.ZodTypeAny = z.ZodNever>(zod: Z) {
const router = useRouter(); const router = useRouter();

View File

@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from '@tanstack/react-query';
export function useRefetchActive() { export function useRefetchActive() {
const client = useQueryClient() const client = useQueryClient();
return () => client.refetchQueries({type: 'active'}) return () => client.refetchQueries({ type: 'active' });
} }

View File

@@ -1,5 +1,5 @@
import { useRouter } from "next/router"; import { useEffect, useRef } from 'react';
import { useEffect, useRef } from "react"; import { useRouter } from 'next/router';
export function useRouterBeforeLeave(callback: () => void) { export function useRouterBeforeLeave(callback: () => void) {
const router = useRouter(); const router = useRouter();
@@ -8,14 +8,14 @@ export function useRouterBeforeLeave(callback: () => void) {
useEffect(() => { useEffect(() => {
const handleRouteChange = (url: string) => { const handleRouteChange = (url: string) => {
if (prevUrl.current !== url) { if (prevUrl.current !== url) {
callback() callback();
} }
prevUrl.current = url; prevUrl.current = url;
}; };
router.events.on("routeChangeStart", handleRouteChange); router.events.on('routeChangeStart', handleRouteChange);
return () => { return () => {
router.events.off("routeChangeStart", handleRouteChange); router.events.off('routeChangeStart', handleRouteChange);
}; };
}, [router, callback]); }, [router, callback]);
} }

View File

@@ -1,146 +1,146 @@
[ [
{ {
"id": "clbow140n0228u99ui1t24l85", "id": "clbow140n0228u99ui1t24l85",
"name": "Mjölkproteinfritt" "name": "Mjölkproteinfritt"
}, },
{ {
"id": "cl04a3trn0015ix50tzy40pyf", "id": "cl04a3trn0015ix50tzy40pyf",
"name": "Måltider" "name": "Måltider"
}, },
{ {
"id": "cl04a5p7s0401ix505n75yfcn", "id": "cl04a5p7s0401ix505n75yfcn",
"name": "Svårighetsgrad" "name": "Svårighetsgrad"
}, },
{ {
"id": "cl04a7k3i0813ix50aau6yxqg", "id": "cl04a7k3i0813ix50aau6yxqg",
"name": "Tid" "name": "Tid"
}, },
{ {
"id": "cl04a47fu0103ix50dqckz2vc", "id": "cl04a47fu0103ix50dqckz2vc",
"name": "Frukost" "name": "Frukost"
}, },
{ {
"id": "cl04a4hvu0152ix50o0w4iy8l", "id": "cl04a4hvu0152ix50o0w4iy8l",
"name": "Mellis" "name": "Mellis"
}, },
{ {
"id": "cl04a58ju0281ix50kdmcwst6", "id": "cl04a58ju0281ix50kdmcwst6",
"name": "Dessert" "name": "Dessert"
}, },
{ {
"id": "cl04a5fjc0321ix50xiwhuydy", "id": "cl04a5fjc0321ix50xiwhuydy",
"name": "Smakportioner" "name": "Smakportioner"
}, },
{ {
"id": "cl04a5kcu0361ix50bnmbhoxz", "id": "cl04a5kcu0361ix50bnmbhoxz",
"name": "Plockmat" "name": "Plockmat"
}, },
{ {
"id": "cl04a60sk0496ix50et419drf", "id": "cl04a60sk0496ix50et419drf",
"name": "Medium" "name": "Medium"
}, },
{ {
"id": "cl04a67lx0536ix50sstoxnhi", "id": "cl04a67lx0536ix50sstoxnhi",
"name": "Avancerat" "name": "Avancerat"
}, },
{ {
"id": "cl04a7qi60850ix50je7vaxo3", "id": "cl04a7qi60850ix50je7vaxo3",
"name": "0-10 min" "name": "0-10 min"
}, },
{ {
"id": "cl04a7vxi0890ix50veumcuyu", "id": "cl04a7vxi0890ix50veumcuyu",
"name": "10-20 min" "name": "10-20 min"
}, },
{ {
"id": "cl04a82bj0930ix50bboh3tl9", "id": "cl04a82bj0930ix50bboh3tl9",
"name": "20-30 min" "name": "20-30 min"
}, },
{ {
"id": "cl04a8a7a0970ix50uet02cqh", "id": "cl04a8a7a0970ix50uet02cqh",
"name": "30-40 min" "name": "30-40 min"
}, },
{ {
"id": "cl04a8g151010ix50z4cnf2kg", "id": "cl04a8g151010ix50z4cnf2kg",
"name": "40-50 min" "name": "40-50 min"
}, },
{ {
"id": "cl04a8mqy1050ix50z0d1ho1a", "id": "cl04a8mqy1050ix50z0d1ho1a",
"name": "50-60 min" "name": "50-60 min"
}, },
{ {
"id": "cl04a5ujg0447ix50vd3vor87", "id": "cl04a5ujg0447ix50vd3vor87",
"name": "Lätt" "name": "Lätt"
}, },
{ {
"id": "cl04a4qv60201ix50b8q5kn9r", "id": "cl04a4qv60201ix50b8q5kn9r",
"name": "Lunch & Middag" "name": "Lunch & Middag"
}, },
{ {
"id": "clak50jem0072ri9ugwygg5ko", "id": "clak50jem0072ri9ugwygg5ko",
"name": "Annat" "name": "Annat"
}, },
{ {
"id": "clak510qm0120ri9upqkca39s", "id": "clak510qm0120ri9upqkca39s",
"name": "För hela familjen" "name": "För hela familjen"
}, },
{ {
"id": "clak59l8x0085yd9uzllcuci5", "id": "clak59l8x0085yd9uzllcuci5",
"name": "Under 3 ingredienser" "name": "Under 3 ingredienser"
}, },
{ {
"id": "clak59l8y0087yd9u53qperp8", "id": "clak59l8y0087yd9u53qperp8",
"name": "Under 5 ingredienser" "name": "Under 5 ingredienser"
}, },
{ {
"id": "claslo2sg0404no9uo2tckm5i", "id": "claslo2sg0404no9uo2tckm5i",
"name": "Huvudingredienser" "name": "Huvudingredienser"
}, },
{ {
"id": "claslv9s20491no9ugo4fd9ns", "id": "claslv9s20491no9ugo4fd9ns",
"name": "Fisk" "name": "Fisk"
}, },
{ {
"id": "claslv9s30493no9umug5po29", "id": "claslv9s30493no9umug5po29",
"name": "Kyckling" "name": "Kyckling"
}, },
{ {
"id": "claslv9s40495no9umor61pql", "id": "claslv9s40495no9umor61pql",
"name": "Kött" "name": "Kött"
}, },
{ {
"id": "claslv9s40497no9uttwkt47n", "id": "claslv9s40497no9uttwkt47n",
"name": "Korv" "name": "Korv"
}, },
{ {
"id": "claslv9s50499no9uch0lhs9i", "id": "claslv9s50499no9uch0lhs9i",
"name": "Vegetariskt" "name": "Vegetariskt"
}, },
{ {
"id": "clb1y44f40128np9uufck0iqf", "id": "clb1y44f40128np9uufck0iqf",
"name": "Årstider" "name": "Årstider"
}, },
{ {
"id": "clb1y4ks80202np9uh43c84ts", "id": "clb1y4ks80202np9uh43c84ts",
"name": "Jul" "name": "Jul"
}, },
{ {
"id": "clbovy0fd0081u99u8dr0yplr", "id": "clbovy0fd0081u99u8dr0yplr",
"name": "Allergier" "name": "Allergier"
}, },
{ {
"id": "clbow140p0230u99uk9e7g1u1", "id": "clbow140p0230u99uk9e7g1u1",
"name": "Äggfritt" "name": "Äggfritt"
}, },
{ {
"id": "clbow140q0232u99uy3lwukvc", "id": "clbow140q0232u99uy3lwukvc",
"name": "Vetefritt" "name": "Vetefritt"
}, },
{ {
"id": "clbow140q0234u99uiyrujxd4", "id": "clbow140q0234u99uiyrujxd4",
"name": "Glutenfritt" "name": "Glutenfritt"
}, },
{ {
"id": "clbow140r0236u99u5333gpei", "id": "clbow140r0236u99u5333gpei",
"name": "Nötfritt" "name": "Nötfritt"
} }
] ]

View File

@@ -1,26 +1,22 @@
import { type Session } from "next-auth"; import { Suspense } from 'react';
import { SessionProvider, getSession } from "next-auth/react"; import { Toaster } from '@/components/ui/toaster';
import App, { import store from '@/redux';
type AppContext, import { api } from '@/utils/api';
type AppInitialProps, import { type Session } from 'next-auth';
type AppType, import { SessionProvider } from 'next-auth/react';
} from "next/app"; import { type AppType } from 'next/app';
import store from "@/redux"; import { Space_Grotesk } from 'next/font/google';
import { Provider as ReduxProvider } from "react-redux"; import { Provider as ReduxProvider } from 'react-redux';
import { Suspense } from "react";
import { Space_Grotesk } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { api } from "@/utils/api"; import '@/styles/globals.css';
import "@/styles/globals.css"; import { TooltipProvider } from '@/components/ui/tooltip';
import { ModalProvider } from "@/modals"; import { ModalProvider } from '@/modals';
import { TooltipProvider } from "@/components/ui/tooltip";
const font = Space_Grotesk({ const font = Space_Grotesk({
subsets: ["latin"], subsets: ['latin'],
display: "swap", display: 'swap',
variable: "--text", variable: '--text',
}); });
const MixanApp: AppType<{ session: Session | null }> = ({ const MixanApp: AppType<{ session: Session | null }> = ({

View File

@@ -1,5 +1,4 @@
import NextAuth from "next-auth"; import { authOptions } from '@/server/auth';
import NextAuth from 'next-auth';
import { authOptions } from "@/server/auth";
export default NextAuth(authOptions); export default NextAuth(authOptions);

View File

@@ -1,24 +1,22 @@
import { validateSdkRequest } from '@/server/auth' import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db' import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions' import { createError, handleError } from '@/server/exceptions';
import { type EventPayload } from '@mixan/types' import type { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from 'next'
import { type EventPayload } from '@mixan/types';
interface Request extends NextApiRequest { interface Request extends NextApiRequest {
body: Array<EventPayload> body: Array<EventPayload>;
} }
export default async function handler( export default async function handler(req: Request, res: NextApiResponse) {
req: Request, if (req.method !== 'POST') {
res: NextApiResponse return handleError(res, createError(405, 'Method not allowed'));
) {
if(req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'))
} }
try { try {
// Check client id & secret // Check client id & secret
const projectId = await validateSdkRequest(req) const projectId = await validateSdkRequest(req);
await db.event.createMany({ await db.event.createMany({
data: req.body.map((event) => ({ data: req.body.map((event) => ({
@@ -27,11 +25,11 @@ export default async function handler(
createdAt: event.time, createdAt: event.time,
project_id: projectId, project_id: projectId,
profile_id: event.profileId, profile_id: event.profileId,
})) })),
}) });
res.status(200).end() res.status(200).end();
} catch (error) { } catch (error) {
handleError(res, error) handleError(res, error);
} }
} }

View File

@@ -1,16 +1,17 @@
import { validateSdkRequest } from "@/server/auth"; import { validateSdkRequest } from '@/server/auth';
import { createError, handleError } from "@/server/exceptions"; import { createError, handleError } from '@/server/exceptions';
import { tickProfileProperty } from "@/server/services/profile.service"; import { tickProfileProperty } from '@/server/services/profile.service';
import { type ProfileIncrementPayload } from "@mixan/types"; import type { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from "next";
import { type ProfileIncrementPayload } from '@mixan/types';
interface Request extends NextApiRequest { interface Request extends NextApiRequest {
body: ProfileIncrementPayload; body: ProfileIncrementPayload;
} }
export default async function handler(req: Request, res: NextApiResponse) { export default async function handler(req: Request, res: NextApiResponse) {
if (req.method !== "PUT") { if (req.method !== 'PUT') {
return handleError(res, createError(405, "Method not allowed")); return handleError(res, createError(405, 'Method not allowed'));
} }
try { try {

View File

@@ -1,21 +1,22 @@
import { validateSdkRequest } from "@/server/auth"; import { validateSdkRequest } from '@/server/auth';
import { createError, handleError } from "@/server/exceptions"; import { createError, handleError } from '@/server/exceptions';
import { tickProfileProperty } from "@/server/services/profile.service"; import { tickProfileProperty } from '@/server/services/profile.service';
import { type ProfileIncrementPayload } from "@mixan/types"; import type { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from "next";
import { type ProfileIncrementPayload } from '@mixan/types';
interface Request extends NextApiRequest { interface Request extends NextApiRequest {
body: ProfileIncrementPayload; body: ProfileIncrementPayload;
} }
export default async function handler(req: Request, res: NextApiResponse) { export default async function handler(req: Request, res: NextApiResponse) {
if (req.method !== "PUT") { if (req.method !== 'PUT') {
return handleError(res, createError(405, "Method not allowed")); return handleError(res, createError(405, 'Method not allowed'));
} }
try { try {
// Check client id & secret // Check client id & secret
await validateSdkRequest(req) await validateSdkRequest(req);
const profileId = req.query.profileId as string; const profileId = req.query.profileId as string;

View File

@@ -1,26 +1,27 @@
import { validateSdkRequest } from "@/server/auth"; import { validateSdkRequest } from '@/server/auth';
import { db } from "@/server/db"; import { db } from '@/server/db';
import { createError, handleError } from "@/server/exceptions"; import { createError, handleError } from '@/server/exceptions';
import { getProfile } from "@/server/services/profile.service"; import { getProfile } from '@/server/services/profile.service';
import { type ProfilePayload } from "@mixan/types"; import type { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from "next";
import { type ProfilePayload } from '@mixan/types';
interface Request extends NextApiRequest { interface Request extends NextApiRequest {
body: ProfilePayload; body: ProfilePayload;
} }
export default async function handler(req: Request, res: NextApiResponse) { export default async function handler(req: Request, res: NextApiResponse) {
if (req.method !== "PUT" && req.method !== "POST") { if (req.method !== 'PUT' && req.method !== 'POST') {
return handleError(res, createError(405, "Method not allowed")); return handleError(res, createError(405, 'Method not allowed'));
} }
try { try {
// Check client id & secret // Check client id & secret
await validateSdkRequest(req) await validateSdkRequest(req);
const profileId = req.query.profileId as string; const profileId = req.query.profileId as string;
const profile = await getProfile(profileId) const profile = await getProfile(profileId);
const { body } = req; const { body } = req;
await db.profile.update({ await db.profile.update({
where: { where: {
@@ -33,7 +34,7 @@ export default async function handler(req: Request, res: NextApiResponse) {
last_name: body.last_name, last_name: body.last_name,
avatar: body.avatar, avatar: body.avatar,
properties: { properties: {
...(typeof profile.properties === "object" ...(typeof profile.properties === 'object'
? profile.properties ?? {} ? profile.properties ?? {}
: {}), : {}),
...(body.properties ?? {}), ...(body.properties ?? {}),

View File

@@ -1,8 +1,8 @@
import { validateSdkRequest } from "@/server/auth"; import { validateSdkRequest } from '@/server/auth';
import { db } from "@/server/db"; import { db } from '@/server/db';
import { createError, handleError } from "@/server/exceptions"; import { createError, handleError } from '@/server/exceptions';
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from 'next';
import randomAnimalName from "random-animal-name"; import randomAnimalName from 'random-animal-name';
interface Request extends NextApiRequest { interface Request extends NextApiRequest {
body: { body: {
@@ -12,8 +12,8 @@ interface Request extends NextApiRequest {
} }
export default async function handler(req: Request, res: NextApiResponse) { export default async function handler(req: Request, res: NextApiResponse) {
if (req.method !== "POST") { if (req.method !== 'POST') {
return handleError(res, createError(405, "Method not allowed")); return handleError(res, createError(405, 'Method not allowed'));
} }
try { try {

View File

@@ -1,10 +1,13 @@
import { db } from "@/server/db"; import { randomUUID } from 'crypto';
import { handleError } from "@/server/exceptions"; import { db } from '@/server/db';
import { hashPassword } from "@/server/services/hash.service"; import { handleError } from '@/server/exceptions';
import { randomUUID } from "crypto"; import { hashPassword } from '@/server/services/hash.service';
import { type NextApiRequest, type NextApiResponse } from "next"; import { type NextApiRequest, type NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try { try {
const counts = await db.$transaction([ const counts = await db.$transaction([
db.organization.count(), db.organization.count(),
@@ -13,25 +16,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
]); ]);
if (counts.some((count) => count > 0)) { if (counts.some((count) => count > 0)) {
return res.json("Setup already done"); return res.json('Setup already done');
} }
const organization = await db.organization.create({ const organization = await db.organization.create({
data: { data: {
name: "Acme Inc.", name: 'Acme Inc.',
}, },
}); });
const project = await db.project.create({ const project = await db.project.create({
data: { data: {
name: "Acme Website", name: 'Acme Website',
organization_id: organization.id, organization_id: organization.id,
}, },
}); });
const secret = randomUUID(); const secret = randomUUID();
const client = await db.client.create({ const client = await db.client.create({
data: { data: {
name: "Acme Website Client", name: 'Acme Website Client',
project_id: project.id, project_id: project.id,
organization_id: organization.id, organization_id: organization.id,
secret: await hashPassword(secret), secret: await hashPassword(secret),

View File

@@ -1,18 +1,17 @@
import { createNextApiHandler } from "@trpc/server/adapters/next"; import { env } from '@/env.mjs';
import { appRouter } from '@/server/api/root';
import { env } from "@/env.mjs"; import { createTRPCContext } from '@/server/api/trpc';
import { appRouter } from "@/server/api/root"; import { createNextApiHandler } from '@trpc/server/adapters/next';
import { createTRPCContext } from "@/server/api/trpc";
// export API handler // export API handler
export default createNextApiHandler({ export default createNextApiHandler({
router: appRouter, router: appRouter,
createContext: createTRPCContext, createContext: createTRPCContext,
onError: onError:
env.NODE_ENV === "development" env.NODE_ENV === 'development'
? ({ path, error }) => { ? ({ path, error }) => {
console.error( console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}` `❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`
); );
} }
: undefined, : undefined,

View File

@@ -1,21 +1,21 @@
import { MainLayout } from "@/components/layouts/MainLayout"; import { useEffect } from 'react';
import { api } from "@/utils/api"; import { MainLayout } from '@/components/layouts/MainLayout';
import { useEffect } from "react"; import { createServerSideProps } from '@/server/getServerSideProps';
import { useRouter } from "next/router"; import { api } from '@/utils/api';
import { createServerSideProps } from "@/server/getServerSideProps"; import { useRouter } from 'next/router';
export const getServerSideProps = createServerSideProps() export const getServerSideProps = createServerSideProps();
export default function Home() { export default function Home() {
const router = useRouter() const router = useRouter();
const query = api.organization.first.useQuery(); const query = api.organization.first.useQuery();
const organization = query.data ?? null; const organization = query.data ?? null;
useEffect(() => { useEffect(() => {
if(organization) { if (organization) {
router.replace(`/${organization.slug}`) router.replace(`/${organization.slug}`);
} }
}, [organization]) }, [organization, router]);
return ( return (
<MainLayout> <MainLayout>

View File

@@ -1,18 +1,21 @@
import reportSlice from '@/components/report/reportSlice' import reportSlice from '@/components/report/reportSlice';
import { configureStore } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit';
import { type TypedUseSelectorHook, useDispatch as useBaseDispatch, useSelector as useBaseSelector } from 'react-redux' import {
useDispatch as useBaseDispatch,
useSelector as useBaseSelector,
type TypedUseSelectorHook,
} from 'react-redux';
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
report: reportSlice report: reportSlice,
}, },
}) });
export type RootState = ReturnType<typeof store.getState> export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch export type AppDispatch = typeof store.dispatch;
export const useDispatch: () => AppDispatch = useBaseDispatch export const useDispatch: () => AppDispatch = useBaseDispatch;
export const useSelector: TypedUseSelectorHook<RootState> = useBaseSelector export const useSelector: TypedUseSelectorHook<RootState> = useBaseSelector;
export default store;
export default store

View File

@@ -1,13 +1,14 @@
import { createTRPCRouter } from "@/server/api/trpc"; import { createTRPCRouter } from '@/server/api/trpc';
import { chartRouter } from "./routers/chart";
import { reportRouter } from "./routers/report"; import { chartRouter } from './routers/chart';
import { organizationRouter } from "./routers/organization"; import { clientRouter } from './routers/client';
import { userRouter } from "./routers/user"; import { dashboardRouter } from './routers/dashboard';
import { projectRouter } from "./routers/project"; import { eventRouter } from './routers/event';
import { clientRouter } from "./routers/client"; import { organizationRouter } from './routers/organization';
import { dashboardRouter } from "./routers/dashboard"; import { profileRouter } from './routers/profile';
import { eventRouter } from "./routers/event"; import { projectRouter } from './routers/project';
import { profileRouter } from "./routers/profile"; import { reportRouter } from './routers/report';
import { userRouter } from './routers/user';
/** /**
* This is the primary router for your server. * This is the primary router for your server.

View File

@@ -1,15 +1,15 @@
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { db } from '@/server/db';
import { db } from "@/server/db";
import { last, pipe, sort, uniq } from "ramda";
import { toDots } from "@/utils/object";
import { zChartInputWithDates } from "@/utils/validation";
import { import {
type IChartInputWithDates,
type IChartEvent, type IChartEvent,
type IChartInputWithDates,
type IChartRange, type IChartRange,
} from "@/types"; } from '@/types';
import { getDaysOldDate } from "@/utils/date"; import { getDaysOldDate } from '@/utils/date';
import { toDots } from '@/utils/object';
import { zChartInputWithDates } from '@/utils/validation';
import { last, pipe, sort, uniq } from 'ramda';
import { z } from 'zod';
export const config = { export const config = {
api: { api: {
@@ -21,7 +21,7 @@ export const chartRouter = createTRPCRouter({
events: protectedProcedure.query(async () => { events: protectedProcedure.query(async () => {
const events = await db.event.findMany({ const events = await db.event.findMany({
take: 500, take: 500,
distinct: ["name"], distinct: ['name'],
}); });
return events; return events;
@@ -47,12 +47,12 @@ export const chartRouter = createTRPCRouter({
const dotNotation = toDots(properties); const dotNotation = toDots(properties);
return [...acc, ...Object.keys(dotNotation)]; return [...acc, ...Object.keys(dotNotation)];
}, [] as string[]) }, [] as string[])
.map((item) => item.replace(/\.([0-9]+)\./g, ".*.")) .map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
.map((item) => item.replace(/\.([0-9]+)/g, "[*]")); .map((item) => item.replace(/\.([0-9]+)/g, '[*]'));
return pipe( return pipe(
sort<string>((a, b) => a.length - b.length), sort<string>((a, b) => a.length - b.length),
uniq, uniq
)(properties); )(properties);
}), }),
@@ -62,10 +62,10 @@ export const chartRouter = createTRPCRouter({
if (isJsonPath(input.property)) { if (isJsonPath(input.property)) {
const events = await db.$queryRawUnsafe<{ value: string }[]>( const events = await db.$queryRawUnsafe<{ value: string }[]>(
`SELECT ${selectJsonPath( `SELECT ${selectJsonPath(
input.property, input.property
)} AS value from events WHERE name = '${ )} AS value from events WHERE name = '${
input.event input.event
}' AND "createdAt" >= NOW() - INTERVAL '30 days'`, }' AND "createdAt" >= NOW() - INTERVAL '30 days'`
); );
return { return {
values: uniq(events.map((item) => item.value)), values: uniq(events.map((item) => item.value)),
@@ -102,12 +102,12 @@ export const chartRouter = createTRPCRouter({
...(await getChartData({ ...(await getChartData({
...input, ...input,
event, event,
})), }))
); );
} }
const sorted = [...series].sort((a, b) => { const sorted = [...series].sort((a, b) => {
if (input.chartType === "linear") { if (input.chartType === 'linear') {
const sumA = a.data.reduce((acc, item) => acc + item.count, 0); const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
const sumB = b.data.reduce((acc, item) => acc + item.count, 0); const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
return sumB - sumA; return sumB - sumA;
@@ -132,8 +132,8 @@ export const chartRouter = createTRPCRouter({
} }
return acc; return acc;
}, },
{} as Record<(typeof series)[number]["event"]["id"], number>, {} as Record<(typeof series)[number]['event']['id'], number>
), )
).map(([id, count]) => ({ ).map(([id, count]) => ({
count, count,
...events.find((event) => event.id === id)!, ...events.find((event) => event.id === id)!,
@@ -148,13 +148,13 @@ export const chartRouter = createTRPCRouter({
function selectJsonPath(property: string) { function selectJsonPath(property: string) {
const jsonPath = property const jsonPath = property
.replace(/^properties\./, "") .replace(/^properties\./, '')
.replace(/\.\*\./g, ".**."); .replace(/\.\*\./g, '.**.');
return `jsonb_path_query(properties, '$.${jsonPath}')`; return `jsonb_path_query(properties, '$.${jsonPath}')`;
} }
function isJsonPath(property: string) { function isJsonPath(property: string) {
return property.startsWith("properties"); return property.startsWith('properties');
} }
type ResultItem = { type ResultItem = {
@@ -164,12 +164,12 @@ type ResultItem = {
}; };
function propertyNameToSql(name: string) { function propertyNameToSql(name: string) {
if (name.includes(".")) { if (name.includes('.')) {
const str = name const str = name
.split(".") .split('.')
.map((item, index) => (index === 0 ? item : `'${item}'`)) .map((item, index) => (index === 0 ? item : `'${item}'`))
.join("->"); .join('->');
const findLastOf = "->"; const findLastOf = '->';
const lastArrow = str.lastIndexOf(findLastOf); const lastArrow = str.lastIndexOf(findLastOf);
if (lastArrow === -1) { if (lastArrow === -1) {
return str; return str;
@@ -224,27 +224,34 @@ function getDatesFromRange(range: IChartRange) {
}; };
} }
function getChartSql({ event, chartType, breakdowns, interval, startDate, endDate }: Omit<IGetChartDataInput, 'range'>) { function getChartSql({
event,
chartType,
breakdowns,
interval,
startDate,
endDate,
}: Omit<IGetChartDataInput, 'range'>) {
const select = []; const select = [];
const where = []; const where = [];
const groupBy = []; const groupBy = [];
const orderBy = []; const orderBy = [];
if (event.segment === "event") { if (event.segment === 'event') {
select.push(`count(*)::int as count`); select.push(`count(*)::int as count`);
} else { } else {
select.push(`count(DISTINCT profile_id)::int as count`); select.push(`count(DISTINCT profile_id)::int as count`);
} }
switch (chartType) { switch (chartType) {
case "bar": { case 'bar': {
orderBy.push("count DESC"); orderBy.push('count DESC');
break; break;
} }
case "linear": { case 'linear': {
select.push(`date_trunc('${interval}', "createdAt") as date`); select.push(`date_trunc('${interval}', "createdAt") as date`);
groupBy.push("date"); groupBy.push('date');
orderBy.push("date"); orderBy.push('date');
break; break;
} }
} }
@@ -256,38 +263,38 @@ function getChartSql({ event, chartType, breakdowns, interval, startDate, endDat
filters.forEach((filter) => { filters.forEach((filter) => {
const { name, value } = filter; const { name, value } = filter;
switch (filter.operator) { switch (filter.operator) {
case "is": { case 'is': {
if (name.includes(".*.") || name.endsWith("[*]")) { if (name.includes('.*.') || name.endsWith('[*]')) {
where.push( where.push(
`properties @? '$.${name `properties @? '$.${name
.replace(/^properties\./, "") .replace(/^properties\./, '')
.replace(/\.\*\./g, "[*].")} ? (${value .replace(/\.\*\./g, '[*].')} ? (${value
.map((val) => `@ == "${val}"`) .map((val) => `@ == "${val}"`)
.join(" || ")})'`, .join(' || ')})'`
); );
} else { } else {
where.push( where.push(
`${propertyNameToSql(name)} in (${value `${propertyNameToSql(name)} in (${value
.map((val) => `'${val}'`) .map((val) => `'${val}'`)
.join(", ")})`, .join(', ')})`
); );
} }
break; break;
} }
case "isNot": { case 'isNot': {
if (name.includes(".*.") || name.endsWith("[*]")) { if (name.includes('.*.') || name.endsWith('[*]')) {
where.push( where.push(
`properties @? '$.${name `properties @? '$.${name
.replace(/^properties\./, "") .replace(/^properties\./, '')
.replace(/\.\*\./g, "[*].")} ? (${value .replace(/\.\*\./g, '[*].')} ? (${value
.map((val) => `@ != "${val}"`) .map((val) => `@ != "${val}"`)
.join(" && ")})'`, .join(' && ')})'`
); );
} else if (name.includes(".")) { } else if (name.includes('.')) {
where.push( where.push(
`${propertyNameToSql(name)} not in (${value `${propertyNameToSql(name)} not in (${value
.map((val) => `'${val}'`) .map((val) => `'${val}'`)
.join(", ")})`, .join(', ')})`
); );
} }
break; break;
@@ -322,24 +329,24 @@ function getChartSql({ event, chartType, breakdowns, interval, startDate, endDat
} }
const sql = [ const sql = [
`SELECT ${select.join(", ")}`, `SELECT ${select.join(', ')}`,
`FROM events`, `FROM events`,
`WHERE ${where.join(" AND ")}`, `WHERE ${where.join(' AND ')}`,
]; ];
if (groupBy.length) { if (groupBy.length) {
sql.push(`GROUP BY ${groupBy.join(", ")}`); sql.push(`GROUP BY ${groupBy.join(', ')}`);
} }
if (orderBy.length) { if (orderBy.length) {
sql.push(`ORDER BY ${orderBy.join(", ")}`); sql.push(`ORDER BY ${orderBy.join(', ')}`);
} }
return sql.join("\n"); return sql.join('\n');
} }
type IGetChartDataInput = { type IGetChartDataInput = {
event: IChartEvent; event: IChartEvent;
} & Omit<IChartInputWithDates, "events" | 'name'> } & Omit<IChartInputWithDates, 'events' | 'name'>;
async function getChartData({ async function getChartData({
chartType, chartType,
@@ -365,23 +372,24 @@ async function getChartData({
interval, interval,
startDate, startDate,
endDate, endDate,
}) });
let result = await db.$queryRawUnsafe<ResultItem[]>(sql); let result = await db.$queryRawUnsafe<ResultItem[]>(sql);
if(result.length === 0 && breakdowns.length > 0) { if (result.length === 0 && breakdowns.length > 0) {
result = await db.$queryRawUnsafe<ResultItem[]>(getChartSql({ result = await db.$queryRawUnsafe<ResultItem[]>(
chartType, getChartSql({
event, chartType,
breakdowns: [], event,
interval, breakdowns: [],
startDate, interval,
endDate, startDate,
})); endDate,
})
);
} }
console.log(sql); console.log(sql);
// group by sql label // group by sql label
const series = result.reduce( const series = result.reduce(
@@ -401,7 +409,7 @@ async function getChartData({
...acc, ...acc,
}; };
}, },
{} as Record<string, ResultItem[]>, {} as Record<string, ResultItem[]>
); );
return Object.keys(series).map((key) => { return Object.keys(series).map((key) => {
@@ -416,7 +424,7 @@ async function getChartData({
}, },
totalCount: getTotalCount(data), totalCount: getTotalCount(data),
data: data:
chartType === "linear" chartType === 'linear'
? fillEmptySpotsInTimeline(data, interval, startDate, endDate).map( ? fillEmptySpotsInTimeline(data, interval, startDate, endDate).map(
(item) => { (item) => {
return { return {
@@ -424,7 +432,7 @@ async function getChartData({
count: item.count, count: item.count,
date: new Date(item.date).toISOString(), date: new Date(item.date).toISOString(),
}; };
}, }
) )
: [], : [],
}; };
@@ -435,17 +443,17 @@ function fillEmptySpotsInTimeline(
items: ResultItem[], items: ResultItem[],
interval: string, interval: string,
startDate: string, startDate: string,
endDate: string, endDate: string
) { ) {
const result = []; const result = [];
const clonedStartDate = new Date(startDate); const clonedStartDate = new Date(startDate);
const clonedEndDate = new Date(endDate); const clonedEndDate = new Date(endDate);
const today = new Date(); const today = new Date();
if(interval === 'minute') {  if (interval === 'minute') {
clonedStartDate.setSeconds(0, 0) clonedStartDate.setSeconds(0, 0);
clonedEndDate.setMinutes(clonedEndDate.getMinutes() + 1, 0, 0); clonedEndDate.setMinutes(clonedEndDate.getMinutes() + 1, 0, 0);
} else if (interval === "hour") { } else if (interval === 'hour') {
clonedStartDate.setMinutes(0, 0, 0); clonedStartDate.setMinutes(0, 0, 0);
clonedEndDate.setMinutes(0, 0, 0); clonedEndDate.setMinutes(0, 0, 0);
} else { } else {
@@ -455,7 +463,7 @@ function fillEmptySpotsInTimeline(
// Force if interval is month and the start date is the same month as today // Force if interval is month and the start date is the same month as today
const shouldForce = () => const shouldForce = () =>
interval === "month" && interval === 'month' &&
clonedStartDate.getFullYear() === today.getFullYear() && clonedStartDate.getFullYear() === today.getFullYear() &&
clonedStartDate.getMonth() === today.getMonth(); clonedStartDate.getMonth() === today.getMonth();
@@ -472,20 +480,20 @@ function fillEmptySpotsInTimeline(
const item = items.find((item) => { const item = items.find((item) => {
const date = new Date(item.date); const date = new Date(item.date);
if (interval === "month") { if (interval === 'month') {
return ( return (
getYear(date) === getYear(clonedStartDate) && getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) getMonth(date) === getMonth(clonedStartDate)
); );
} }
if (interval === "day") { if (interval === 'day') {
return ( return (
getYear(date) === getYear(clonedStartDate) && getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) && getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) getDay(date) === getDay(clonedStartDate)
); );
} }
if (interval === "hour") { if (interval === 'hour') {
return ( return (
getYear(date) === getYear(clonedStartDate) && getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) && getMonth(date) === getMonth(clonedStartDate) &&
@@ -493,7 +501,7 @@ function fillEmptySpotsInTimeline(
getHour(date) === getHour(clonedStartDate) getHour(date) === getHour(clonedStartDate)
); );
} }
if (interval === "minute") { if (interval === 'minute') {
return ( return (
getYear(date) === getYear(clonedStartDate) && getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) && getMonth(date) === getMonth(clonedStartDate) &&
@@ -515,19 +523,19 @@ function fillEmptySpotsInTimeline(
} }
switch (interval) { switch (interval) {
case "day": { case 'day': {
clonedStartDate.setDate(clonedStartDate.getDate() + 1); clonedStartDate.setDate(clonedStartDate.getDate() + 1);
break; break;
} }
case "hour": { case 'hour': {
clonedStartDate.setHours(clonedStartDate.getHours() + 1); clonedStartDate.setHours(clonedStartDate.getHours() + 1);
break; break;
} }
case "minute": { case 'minute': {
clonedStartDate.setMinutes(clonedStartDate.getMinutes() + 1); clonedStartDate.setMinutes(clonedStartDate.getMinutes() + 1);
break; break;
} }
case "month": { case 'month': {
clonedStartDate.setMonth(clonedStartDate.getMonth() + 1); clonedStartDate.setMonth(clonedStartDate.getMonth() + 1);
break; break;
} }

View File

@@ -1,17 +1,16 @@
import { z } from "zod"; import { randomUUID } from 'crypto';
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { db } from '@/server/db';
import { db } from "@/server/db"; import { hashPassword } from '@/server/services/hash.service';
import { hashPassword } from "@/server/services/hash.service"; import { getOrganizationBySlug } from '@/server/services/organization.service';
import { randomUUID } from "crypto"; import { z } from 'zod';
import { getOrganizationBySlug } from "@/server/services/organization.service";
export const clientRouter = createTRPCRouter({ export const clientRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
.input( .input(
z.object({ z.object({
organizationSlug: z.string(), organizationSlug: z.string(),
}), })
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug); const organization = await getOrganizationBySlug(input.organizationSlug);
@@ -28,7 +27,7 @@ export const clientRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
}), })
) )
.query(({ input }) => { .query(({ input }) => {
return db.client.findUniqueOrThrow({ return db.client.findUniqueOrThrow({
@@ -42,7 +41,7 @@ export const clientRouter = createTRPCRouter({
z.object({ z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
}), })
) )
.mutation(({ input }) => { .mutation(({ input }) => {
return db.client.update({ return db.client.update({
@@ -60,7 +59,7 @@ export const clientRouter = createTRPCRouter({
name: z.string(), name: z.string(),
projectId: z.string(), projectId: z.string(),
organizationSlug: z.string(), organizationSlug: z.string(),
}), })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug); const organization = await getOrganizationBySlug(input.organizationSlug);
@@ -83,7 +82,7 @@ export const clientRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
}), })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await db.client.delete({ await db.client.delete({

View File

@@ -1,9 +1,8 @@
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { getProjectBySlug } from '@/server/services/project.service';
import { db } from "@/server/db"; import { slug } from '@/utils/slug';
import { getProjectBySlug } from "@/server/services/project.service"; import { z } from 'zod';
import { slug } from "@/utils/slug";
export const dashboardRouter = createTRPCRouter({ export const dashboardRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
@@ -15,12 +14,12 @@ export const dashboardRouter = createTRPCRouter({
.or( .or(
z.object({ z.object({
projectId: z.string(), projectId: z.string(),
}), })
), )
) )
.query(async ({ input }) => { .query(async ({ input }) => {
let projectId = null; let projectId = null;
if ("projectId" in input) { if ('projectId' in input) {
projectId = input.projectId; projectId = input.projectId;
} else { } else {
projectId = (await getProjectBySlug(input.projectSlug)).id; projectId = (await getProjectBySlug(input.projectSlug)).id;
@@ -37,7 +36,7 @@ export const dashboardRouter = createTRPCRouter({
z.object({ z.object({
name: z.string(), name: z.string(),
projectId: z.string(), projectId: z.string(),
}), })
) )
.mutation(async ({ input: { projectId, name } }) => { .mutation(async ({ input: { projectId, name } }) => {
return db.dashboard.create({ return db.dashboard.create({

View File

@@ -1,6 +1,6 @@
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { db } from '@/server/db';
import { db } from "@/server/db"; import { z } from 'zod';
export const config = { export const config = {
api: { api: {
@@ -16,7 +16,7 @@ export const eventRouter = createTRPCRouter({
take: z.number().default(100), take: z.number().default(100),
skip: z.number().default(0), skip: z.number().default(0),
profileId: z.string().optional(), profileId: z.string().optional(),
}), })
) )
.query(async ({ input: { take, skip, projectSlug, profileId } }) => { .query(async ({ input: { take, skip, projectSlug, profileId } }) => {
const project = await db.project.findUniqueOrThrow({ const project = await db.project.findUniqueOrThrow({
@@ -29,10 +29,10 @@ export const eventRouter = createTRPCRouter({
skip, skip,
where: { where: {
project_id: project.id, project_id: project.id,
profile_id: profileId profile_id: profileId,
}, },
orderBy: { orderBy: {
createdAt: "desc", createdAt: 'desc',
}, },
include: { include: {
profile: true, profile: true,

View File

@@ -1,9 +1,8 @@
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { getOrganizationBySlug } from '@/server/services/organization.service';
import { db } from "@/server/db"; import { slug } from '@/utils/slug';
import { getOrganizationBySlug } from "@/server/services/organization.service"; import { z } from 'zod';
import { slug } from "@/utils/slug";
export const organizationRouter = createTRPCRouter({ export const organizationRouter = createTRPCRouter({
first: protectedProcedure.query(({ ctx }) => { first: protectedProcedure.query(({ ctx }) => {
@@ -21,17 +20,17 @@ export const organizationRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
slug: z.string(), slug: z.string(),
}), })
) )
.query(({ input }) => { .query(({ input }) => {
return getOrganizationBySlug(input.slug) return getOrganizationBySlug(input.slug);
}), }),
update: protectedProcedure update: protectedProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
}), })
) )
.mutation(({ input }) => { .mutation(({ input }) => {
return db.organization.update({ return db.organization.update({
@@ -40,7 +39,7 @@ export const organizationRouter = createTRPCRouter({
}, },
data: { data: {
name: input.name, name: input.name,
slug: slug(input.name) slug: slug(input.name),
}, },
}); });
}), }),

View File

@@ -1,6 +1,6 @@
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { db } from '@/server/db';
import { db } from "@/server/db"; import { z } from 'zod';
export const config = { export const config = {
api: { api: {
@@ -15,7 +15,7 @@ export const profileRouter = createTRPCRouter({
projectSlug: z.string(), projectSlug: z.string(),
take: z.number().default(100), take: z.number().default(100),
skip: z.number().default(0), skip: z.number().default(0),
}), })
) )
.query(async ({ input: { take, skip, projectSlug } }) => { .query(async ({ input: { take, skip, projectSlug } }) => {
const project = await db.project.findUniqueOrThrow({ const project = await db.project.findUniqueOrThrow({
@@ -30,7 +30,7 @@ export const profileRouter = createTRPCRouter({
project_id: project.id, project_id: project.id,
}, },
orderBy: { orderBy: {
createdAt: "desc", createdAt: 'desc',
}, },
}); });
}), }),

View File

@@ -1,15 +1,14 @@
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { getOrganizationBySlug } from '@/server/services/organization.service';
import { db } from "@/server/db"; import { z } from 'zod';
import { getOrganizationBySlug } from "@/server/services/organization.service";
export const projectRouter = createTRPCRouter({ export const projectRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
.input( .input(
z.object({ z.object({
organizationSlug: z.string(), organizationSlug: z.string(),
}), })
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug); const organization = await getOrganizationBySlug(input.organizationSlug);
@@ -23,7 +22,7 @@ export const projectRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
}), })
) )
.query(({ input }) => { .query(({ input }) => {
return db.project.findUniqueOrThrow({ return db.project.findUniqueOrThrow({
@@ -37,7 +36,7 @@ export const projectRouter = createTRPCRouter({
z.object({ z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
}), })
) )
.mutation(({ input }) => { .mutation(({ input }) => {
return db.project.update({ return db.project.update({
@@ -54,7 +53,7 @@ export const projectRouter = createTRPCRouter({
z.object({ z.object({
name: z.string(), name: z.string(),
organizationSlug: z.string(), organizationSlug: z.string(),
}), })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug); const organization = await getOrganizationBySlug(input.organizationSlug);
@@ -69,7 +68,7 @@ export const projectRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
}), })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await db.project.delete({ await db.project.delete({

View File

@@ -1,48 +1,54 @@
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { getDashboardBySlug } from '@/server/services/dashboard.service';
import { zChartInput } from "@/utils/validation"; import { getProjectBySlug } from '@/server/services/project.service';
import { db } from "@/server/db";
import { import {
type IChartInput,
type IChartBreakdown, type IChartBreakdown,
type IChartEvent, type IChartEvent,
type IChartEventFilter, type IChartEventFilter,
type IChartInput,
type IChartRange, type IChartRange,
} from "@/types"; } from '@/types';
import { type Report as DbReport } from "@prisma/client"; import { alphabetIds } from '@/utils/constants';
import { getProjectBySlug } from "@/server/services/project.service"; import { zChartInput } from '@/utils/validation';
import { getDashboardBySlug } from "@/server/services/dashboard.service"; import { type Report as DbReport } from '@prisma/client';
import { alphabetIds } from "@/utils/constants"; import { z } from 'zod';
function transformFilter(filter: Partial<IChartEventFilter>, index: number): IChartEventFilter { function transformFilter(
filter: Partial<IChartEventFilter>,
index: number
): IChartEventFilter {
return { return {
id: filter.id ?? alphabetIds[index]!, id: filter.id ?? alphabetIds[index]!,
name: filter.name ?? 'Unknown Filter', name: filter.name ?? 'Unknown Filter',
operator: filter.operator ?? 'is', operator: filter.operator ?? 'is',
value: typeof filter.value === 'string' ? [filter.value] : filter.value ?? [], value:
} typeof filter.value === 'string' ? [filter.value] : filter.value ?? [],
};
} }
function transformEvent(event: Partial<IChartEvent>, index: number): IChartEvent { function transformEvent(
event: Partial<IChartEvent>,
index: number
): IChartEvent {
return { return {
segment: event.segment ?? 'event', segment: event.segment ?? 'event',
filters: (event.filters ?? []).map(transformFilter), filters: (event.filters ?? []).map(transformFilter),
id: event.id ?? alphabetIds[index]!, id: event.id ?? alphabetIds[index]!,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: event.name || 'Untitled', name: event.name || 'Untitled',
} };
} }
function transformReport(report: DbReport): IChartInput & { id: string } { function transformReport(report: DbReport): IChartInput & { id: string } {
return { return {
id: report.id, id: report.id,
events: (report.events as IChartEvent[]).map(transformEvent), events: (report.events as IChartEvent[]).map(transformEvent),
breakdowns: report.breakdowns as IChartBreakdown[], breakdowns: report.breakdowns as IChartBreakdown[],
chartType: report.chart_type, chartType: report.chart_type,
interval: report.interval, interval: report.interval,
name: report.name || 'Untitled', name: report.name || 'Untitled',
range: report.range as IChartRange ?? 30, range: (report.range as IChartRange) ?? 30,
}; };
} }
@@ -51,7 +57,7 @@ export const reportRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
}), })
) )
.query(({ input: { id } }) => { .query(({ input: { id } }) => {
return db.report return db.report
@@ -67,7 +73,7 @@ export const reportRouter = createTRPCRouter({
z.object({ z.object({
projectSlug: z.string(), projectSlug: z.string(),
dashboardSlug: z.string(), dashboardSlug: z.string(),
}), })
) )
.query(async ({ input: { projectSlug, dashboardSlug } }) => { .query(async ({ input: { projectSlug, dashboardSlug } }) => {
const project = await getProjectBySlug(projectSlug); const project = await getProjectBySlug(projectSlug);
@@ -82,7 +88,7 @@ export const reportRouter = createTRPCRouter({
return { return {
reports: reports.map(transformReport), reports: reports.map(transformReport),
dashboard, dashboard,
} };
}), }),
save: protectedProcedure save: protectedProcedure
.input( .input(
@@ -90,7 +96,7 @@ export const reportRouter = createTRPCRouter({
report: zChartInput, report: zChartInput,
projectId: z.string(), projectId: z.string(),
dashboardId: z.string(), dashboardId: z.string(),
}), })
) )
.mutation(({ input: { report, projectId, dashboardId } }) => { .mutation(({ input: { report, projectId, dashboardId } }) => {
return db.report.create({ return db.report.create({
@@ -105,7 +111,6 @@ export const reportRouter = createTRPCRouter({
range: report.range, range: report.range,
}, },
}); });
}), }),
update: protectedProcedure update: protectedProcedure
.input( .input(
@@ -114,7 +119,7 @@ export const reportRouter = createTRPCRouter({
report: zChartInput, report: zChartInput,
projectId: z.string(), projectId: z.string(),
dashboardId: z.string(), dashboardId: z.string(),
}), })
) )
.mutation(({ input: { report, projectId, dashboardId, reportId } }) => { .mutation(({ input: { report, projectId, dashboardId, reportId } }) => {
return db.report.update({ return db.report.update({

View File

@@ -1,63 +1,59 @@
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { import { hashPassword, verifyPassword } from '@/server/services/hash.service';
createTRPCRouter, import { z } from 'zod';
protectedProcedure,
} from "@/server/api/trpc";
import { db } from "@/server/db";
import { hashPassword, verifyPassword } from "@/server/services/hash.service";
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
current: protectedProcedure.query(({ ctx }) => { current: protectedProcedure.query(({ ctx }) => {
return db.user.findUniqueOrThrow({ return db.user.findUniqueOrThrow({
where: { where: {
id: ctx.session.user.id id: ctx.session.user.id,
} },
}) });
}), }),
update: protectedProcedure update: protectedProcedure
.input( .input(
z.object({ z.object({
name: z.string(), name: z.string(),
email: z.string(), email: z.string(),
}), })
) )
.mutation(({ input, ctx }) => { .mutation(({ input, ctx }) => {
return db.user.update({ return db.user.update({
where: { where: {
id: ctx.session.user.id id: ctx.session.user.id,
}, },
data: { data: {
name: input.name, name: input.name,
email: input.email, email: input.email,
} },
}) });
}), }),
changePassword: protectedProcedure changePassword: protectedProcedure
.input( .input(
z.object({ z.object({
password: z.string(), password: z.string(),
oldPassword: z.string(), oldPassword: z.string(),
}), })
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const user = await db.user.findUniqueOrThrow({ const user = await db.user.findUniqueOrThrow({
where: { where: {
id: ctx.session.user.id id: ctx.session.user.id,
} },
}) });
if(!(await verifyPassword(input.oldPassword, user.password))) { if (!(await verifyPassword(input.oldPassword, user.password))) {
throw new Error('Old password is incorrect') throw new Error('Old password is incorrect');
} }
return db.user.update({ return db.user.update({
where: { where: {
id: ctx.session.user.id id: ctx.session.user.id,
}, },
data: { data: {
password: await hashPassword(input.password), password: await hashPassword(input.password),
} },
}) });
}), }),
}); });

View File

@@ -7,14 +7,13 @@
* need to use are documented accordingly near the end. * need to use are documented accordingly near the end.
*/ */
import { initTRPC, TRPCError } from "@trpc/server"; import { getServerAuthSession } from '@/server/auth';
import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; import { db } from '@/server/db';
import { type Session } from "next-auth"; import { initTRPC, TRPCError } from '@trpc/server';
import superjson from "superjson"; import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
import { ZodError } from "zod"; import { type Session } from 'next-auth';
import superjson from 'superjson';
import { getServerAuthSession } from "@/server/auth"; import { ZodError } from 'zod';
import { db } from "@/server/db";
/** /**
* 1. CONTEXT * 1. CONTEXT
@@ -109,7 +108,7 @@ export const publicProcedure = t.procedure;
/** Reusable middleware that enforces users are logged in before running the procedure. */ /** Reusable middleware that enforces users are logged in before running the procedure. */
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) { if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
return next({ return next({
ctx: { ctx: {

View File

@@ -1,14 +1,14 @@
import { type NextApiRequest, type GetServerSidePropsContext } from "next"; import { db } from '@/server/db';
import { verifyPassword } from '@/server/services/hash.service';
import { type GetServerSidePropsContext, type NextApiRequest } from 'next';
import { import {
getServerSession, getServerSession,
type DefaultSession, type DefaultSession,
type NextAuthOptions, type NextAuthOptions,
} from "next-auth"; } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { db } from "@/server/db"; import { createError } from './exceptions';
import Credentials from "next-auth/providers/credentials";
import { createError } from "./exceptions";
import { hashPassword, verifyPassword } from "@/server/services/hash.service";
/** /**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
@@ -16,9 +16,9 @@ import { hashPassword, verifyPassword } from "@/server/services/hash.service";
* *
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation * @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/ */
declare module "next-auth" { declare module 'next-auth' {
interface Session extends DefaultSession { interface Session extends DefaultSession {
user: DefaultSession["user"] & { user: DefaultSession['user'] & {
id: string; id: string;
}; };
} }
@@ -45,35 +45,35 @@ export const authOptions: NextAuthOptions = {
}), }),
}, },
session: { session: {
strategy: "jwt", strategy: 'jwt',
}, },
providers: [ providers: [
Credentials({ Credentials({
name: "Credentials", name: 'Credentials',
credentials: { credentials: {
email: { label: "Email", type: "text", placeholder: "jsmith" }, email: { label: 'Email', type: 'text', placeholder: 'jsmith' },
password: { label: "Password", type: "password" }, password: { label: 'Password', type: 'password' },
}, },
async authorize(credentials) { async authorize(credentials) {
if(!credentials?.password || !credentials?.email) { if (!credentials?.password || !credentials?.email) {
return null return null;
} }
const user = await db.user.findFirst({ const user = await db.user.findFirst({
where: { email: credentials?.email }, where: { email: credentials?.email },
}); });
if(!user) { if (!user) {
return null return null;
} }
if(!await verifyPassword(credentials.password, user.password)) { if (!(await verifyPassword(credentials.password, user.password))) {
return null return null;
} }
return { return {
...user, ...user,
image: 'https://api.dicebear.com/7.x/adventurer/svg?seed=Abby' image: 'https://api.dicebear.com/7.x/adventurer/svg?seed=Abby',
}; };
}, },
}), }),
@@ -95,22 +95,22 @@ export const authOptions: NextAuthOptions = {
* @see https://next-auth.js.org/configuration/nextjs * @see https://next-auth.js.org/configuration/nextjs
*/ */
export const getServerAuthSession = (ctx: { export const getServerAuthSession = (ctx: {
req: GetServerSidePropsContext["req"]; req: GetServerSidePropsContext['req'];
res: GetServerSidePropsContext["res"]; res: GetServerSidePropsContext['res'];
}) => { }) => {
return getServerSession(ctx.req, ctx.res, authOptions); return getServerSession(ctx.req, ctx.res, authOptions);
}; };
export async function validateSdkRequest(req: NextApiRequest): Promise<string> { export async function validateSdkRequest(req: NextApiRequest): Promise<string> {
const clientId = req?.headers["mixan-client-id"] as string | undefined const clientId = req?.headers['mixan-client-id'] as string | undefined;
const clientSecret = req.headers["mixan-client-secret"] as string | undefined const clientSecret = req.headers['mixan-client-secret'] as string | undefined;
if (!clientId) { if (!clientId) {
throw createError(401, "Misisng client id"); throw createError(401, 'Misisng client id');
} }
if (!clientSecret) { if (!clientSecret) {
throw createError(401, "Misisng client secret"); throw createError(401, 'Misisng client secret');
} }
const client = await db.client.findUnique({ const client = await db.client.findUnique({
@@ -120,14 +120,12 @@ export async function validateSdkRequest(req: NextApiRequest): Promise<string> {
}); });
if (!client) { if (!client) {
throw createError(401, "Invalid client id"); throw createError(401, 'Invalid client id');
} }
if (!(await verifyPassword(clientSecret, client.secret))) { if (!(await verifyPassword(clientSecret, client.secret))) {
throw createError(401, "Invalid client secret"); throw createError(401, 'Invalid client secret');
} }
return client.project_id return client.project_id;
} }

View File

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

View File

@@ -1,55 +1,52 @@
import { import { type NextApiResponse } from 'next';
type MixanIssue,
type MixanErrorResponse import { type MixanErrorResponse, type MixanIssue } from '@mixan/types';
} from '@mixan/types'
import { type NextApiResponse } from 'next'
export class HttpError extends Error { export class HttpError extends Error {
public status: number public status: number;
public message: string public message: string;
public issues: MixanIssue[] public issues: MixanIssue[];
constructor(status: number, message: string | Error, issues?: MixanIssue[]) { constructor(status: number, message: string | Error, issues?: MixanIssue[]) {
super(message instanceof Error ? message.message : message) super(message instanceof Error ? message.message : message);
this.status = status this.status = status;
this.message = message instanceof Error ? message.message : message this.message = message instanceof Error ? message.message : message;
this.issues = issues ?? [] this.issues = issues ?? [];
} }
toJson(): MixanErrorResponse { toJson(): MixanErrorResponse {
return { return {
code: this.status, code: this.status,
status: 'error', status: 'error',
message: this.message, message: this.message,
issues: this.issues.length ? this.issues : undefined, issues: this.issues.length ? this.issues : undefined,
stack: process.env.NODE_ENV !== 'production' ? this.stack : undefined, stack: process.env.NODE_ENV !== 'production' ? this.stack : undefined,
} };
} }
} }
export function createIssues(arr: Array<MixanIssue>) { export function createIssues(arr: Array<MixanIssue>) {
throw new HttpError(400, 'Issues', arr) throw new HttpError(400, 'Issues', arr);
} }
export function createError(status = 500, error: unknown) { export function createError(status = 500, error: unknown) {
if(error instanceof Error || typeof error === 'string') { if (error instanceof Error || typeof error === 'string') {
return new HttpError(status, error) return new HttpError(status, error);
} }
return new HttpError(500, 'Unexpected error occured') return new HttpError(500, 'Unexpected error occured');
} }
export function handleError(res: NextApiResponse, error: unknown) { export function handleError(res: NextApiResponse, error: unknown) {
if(error instanceof HttpError) { if (error instanceof HttpError) {
return res.status(error.status).json(error.toJson()) return res.status(error.status).json(error.toJson());
}
if(error instanceof Error) {
const httpError = createError(500, error)
res.status(httpError.status).json(httpError.toJson())
} }
if (error instanceof Error) {
const httpError = createError(500, error) const httpError = createError(500, error);
res.status(httpError.status).json(httpError.toJson()) res.status(httpError.status).json(httpError.toJson());
} }
const httpError = createError(500, error);
res.status(httpError.status).json(httpError.toJson());
}

View File

@@ -1,45 +1,46 @@
import { import {
type GetServerSidePropsContext, type GetServerSidePropsContext,
type GetServerSidePropsResult, type GetServerSidePropsResult,
} from "next"; } from 'next';
import { getServerAuthSession } from "./auth";
import { db } from "./db"; import { getServerAuthSession } from './auth';
import { db } from './db';
export function createServerSideProps( export function createServerSideProps(
cb?: (context: GetServerSidePropsContext) => Promise<any>, cb?: (context: GetServerSidePropsContext) => Promise<any>
) { ) {
return async function getServerSideProps( return async function getServerSideProps(
context: GetServerSidePropsContext, context: GetServerSidePropsContext
): Promise<GetServerSidePropsResult<any>> { ): Promise<GetServerSidePropsResult<any>> {
const session = await getServerAuthSession(context); const session = await getServerAuthSession(context);
if(!session) { if (!session) {
return { return {
redirect: { redirect: {
destination: "/api/auth/signin", destination: '/api/auth/signin',
permanent: false, permanent: false,
}, },
} };
} }
if(context.params?.organization) { if (context.params?.organization) {
const organization = await db.user.findFirst({ const organization = await db.user.findFirst({
where: { where: {
id: session.user.id, id: session.user.id,
organization: { organization: {
slug: context.params.organization as string slug: context.params.organization as string,
} },
} },
}) });
if(!organization) { if (!organization) {
return { return {
notFound: true, notFound: true,
} };
} }
} }
const res = await (typeof cb === "function" const res = await (typeof cb === 'function'
? cb(context) ? cb(context)
: Promise.resolve({})); : Promise.resolve({}));
return { return {

View File

@@ -1,9 +1,9 @@
import { db } from "../db"; import { db } from '../db';
export function getDashboardBySlug(slug: string) { export function getDashboardBySlug(slug: string) {
return db.dashboard.findUniqueOrThrow({ return db.dashboard.findUniqueOrThrow({
where: { where: {
slug slug,
}, },
}); });
} }

View File

@@ -1,4 +1,4 @@
import { scrypt, randomBytes, timingSafeEqual } from "crypto"; import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
const keyLength = 32; const keyLength = 32;
/** /**
@@ -6,17 +6,17 @@ const keyLength = 32;
* @param {string} password * @param {string} password
* @returns {string} The salt+hash * @returns {string} The salt+hash
*/ */
export async function hashPassword (password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// generate random 16 bytes long salt - recommended by NodeJS Docs // generate random 16 bytes long salt - recommended by NodeJS Docs
const salt = randomBytes(16).toString("hex"); const salt = randomBytes(16).toString('hex');
scrypt(password, salt, keyLength, (err, derivedKey) => { scrypt(password, salt, keyLength, (err, derivedKey) => {
if (err) reject(err); if (err) reject(err);
// derivedKey is of type Buffer // derivedKey is of type Buffer
resolve(`${salt}.${derivedKey.toString("hex")}`); resolve(`${salt}.${derivedKey.toString('hex')}`);
}); });
}); });
}; }
/** /**
* Compare a plain text password with a salt+hash password * Compare a plain text password with a salt+hash password
@@ -24,11 +24,14 @@ export async function hashPassword (password: string): Promise<string> {
* @param {string} hash The hash+salt to check against * @param {string} hash The hash+salt to check against
* @returns {boolean} * @returns {boolean}
*/ */
export async function verifyPassword (password: string, hash: string): Promise<boolean> { export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const [salt, hashKey] = hash.split("."); const [salt, hashKey] = hash.split('.');
// we need to pass buffer values to timingSafeEqual // we need to pass buffer values to timingSafeEqual
const hashKeyBuff = Buffer.from(hashKey!, "hex"); const hashKeyBuff = Buffer.from(hashKey!, 'hex');
scrypt(password, salt!, keyLength, (err, derivedKey) => { scrypt(password, salt!, keyLength, (err, derivedKey) => {
if (err) { if (err) {
reject(err); reject(err);
@@ -37,4 +40,4 @@ export async function verifyPassword (password: string, hash: string): Promise<b
resolve(timingSafeEqual(hashKeyBuff, derivedKey)); resolve(timingSafeEqual(hashKeyBuff, derivedKey));
}); });
}); });
}; }

View File

@@ -1,9 +1,9 @@
import { db } from "../db"; import { db } from '../db';
export function getOrganizationBySlug(slug: string) { export function getOrganizationBySlug(slug: string) {
return db.organization.findUniqueOrThrow({ return db.organization.findUniqueOrThrow({
where: { where: {
slug slug,
}, },
}); });
} }

View File

@@ -1,12 +1,12 @@
import { db } from "@/server/db" import { db } from '@/server/db';
import { HttpError } from "@/server/exceptions" import { HttpError } from '@/server/exceptions';
export function getProfile(id: string) { export function getProfile(id: string) {
return db.profile.findUniqueOrThrow({ return db.profile.findUniqueOrThrow({
where: { where: {
id, id,
}, },
}) });
} }
export async function tickProfileProperty({ export async function tickProfileProperty({
@@ -14,28 +14,31 @@ export async function tickProfileProperty({
tick, tick,
name, name,
}: { }: {
profileId: string profileId: string;
tick: number tick: number;
name: string name: string;
}) { }) {
const profile = await getProfile(profileId) const profile = await getProfile(profileId);
if (!profile) { if (!profile) {
throw new HttpError(404, `Profile not found ${profileId}`) throw new HttpError(404, `Profile not found ${profileId}`);
} }
const properties = ( const properties = (
typeof profile.properties === 'object' ? profile.properties ?? {} : {} typeof profile.properties === 'object' ? profile.properties ?? {} : {}
) as Record<string, number> ) as Record<string, number>;
const value = name in properties ? properties[name] : 0 const value = name in properties ? properties[name] : 0;
if (typeof value !== 'number') { if (typeof value !== 'number') {
throw new HttpError(400, `Property "${name}" on user is of type ${typeof value}`) throw new HttpError(
400,
`Property "${name}" on user is of type ${typeof value}`
);
} }
if (typeof tick !== 'number') { if (typeof tick !== 'number') {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new HttpError(400, `Value is not a number ${tick} (${typeof tick})`) throw new HttpError(400, `Value is not a number ${tick} (${typeof tick})`);
} }
await db.profile.update({ await db.profile.update({
@@ -48,5 +51,5 @@ export async function tickProfileProperty({
[name]: value + tick, [name]: value + tick,
}, },
}, },
}) });
} }

View File

@@ -1,9 +1,9 @@
import { db } from "../db"; import { db } from '../db';
export function getProjectBySlug(slug: string) { export function getProjectBySlug(slug: string) {
return db.project.findUniqueOrThrow({ return db.project.findUniqueOrThrow({
where: { where: {
slug slug,
}, },
}); });
} }

View File

@@ -1,27 +1,35 @@
import { type RouterOutputs } from "@/utils/api"; import { type RouterOutputs } from '@/utils/api';
import { type timeRanges } from "@/utils/constants"; import { type timeRanges } from '@/utils/constants';
import { type zTimeInterval, type zChartBreakdown, type zChartEvent, type zChartInput, type zChartType, type zChartInputWithDates } from "@/utils/validation"; import {
import { type Client, type Project } from "@prisma/client"; type zChartBreakdown,
import { type TooltipProps } from "recharts"; type zChartEvent,
import { type z } from "zod"; type zChartInput,
type zChartInputWithDates,
type zChartType,
type zTimeInterval,
} from '@/utils/validation';
import { type Client, type Project } from '@prisma/client';
import { type TooltipProps } from 'recharts';
import { type z } from 'zod';
export type HtmlProps<T> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T>; export type HtmlProps<T> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T>;
export type IChartInput = z.infer<typeof zChartInput> export type IChartInput = z.infer<typeof zChartInput>;
export type IChartInputWithDates = z.infer<typeof zChartInputWithDates> export type IChartInputWithDates = z.infer<typeof zChartInputWithDates>;
export type IChartEvent = z.infer<typeof zChartEvent> export type IChartEvent = z.infer<typeof zChartEvent>;
export type IChartEventFilter = IChartEvent['filters'][number] export type IChartEventFilter = IChartEvent['filters'][number];
export type IChartEventFilterValue = IChartEvent['filters'][number]['value'][number] export type IChartEventFilterValue =
export type IChartBreakdown = z.infer<typeof zChartBreakdown> IChartEvent['filters'][number]['value'][number];
export type IInterval = z.infer<typeof zTimeInterval> export type IChartBreakdown = z.infer<typeof zChartBreakdown>;
export type IChartType = z.infer<typeof zChartType> export type IInterval = z.infer<typeof zTimeInterval>;
export type IChartData = RouterOutputs["chart"]["chart"]; export type IChartType = z.infer<typeof zChartType>;
export type IChartRange = typeof timeRanges[number]['range']; export type IChartData = RouterOutputs['chart']['chart'];
export type IChartRange = (typeof timeRanges)[number]['range'];
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & { export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
payload?: Array<T> payload?: Array<T>;
} };
export type IProject = Project export type IProject = Project;
export type IClientWithProject = Client & { export type IClientWithProject = Client & {
project: IProject project: IProject;
} };

View File

@@ -4,16 +4,15 @@
* *
* We also create a few inference helpers for input and output types. * We also create a few inference helpers for input and output types.
*/ */
import { type TRPCClientErrorBase, httpLink, loggerLink } from "@trpc/client"; import { toast } from '@/components/ui/use-toast';
import { createTRPCNext } from "@trpc/next"; import { type AppRouter } from '@/server/api/root';
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; import { httpLink, loggerLink, type TRPCClientErrorBase } from '@trpc/client';
import superjson from "superjson"; import { createTRPCNext } from '@trpc/next';
import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server';
import { type AppRouter } from "@/server/api/root"; import superjson from 'superjson';
import { toast } from "@/components/ui/use-toast";
const getBaseUrl = () => { const getBaseUrl = () => {
if (typeof window !== "undefined") return ""; // browser should use relative url if (typeof window !== 'undefined') return ''; // browser should use relative url
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
}; };
@@ -28,9 +27,9 @@ export const api = createTRPCNext<AppRouter>({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
refetchOnMount: false, refetchOnMount: false,
enabled: typeof window !== "undefined", enabled: typeof window !== 'undefined',
}, },
} },
}, },
/** /**
* Transformer used for data de-serialization from the server. * Transformer used for data de-serialization from the server.
@@ -47,8 +46,8 @@ export const api = createTRPCNext<AppRouter>({
links: [ links: [
loggerLink({ loggerLink({
enabled: (opts) => enabled: (opts) =>
process.env.NODE_ENV === "development" || process.env.NODE_ENV === 'development' ||
(opts.direction === "down" && opts.result instanceof Error), (opts.direction === 'down' && opts.result instanceof Error),
}), }),
httpLink({ httpLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
@@ -78,10 +77,9 @@ export type RouterInputs = inferRouterInputs<AppRouter>;
*/ */
export type RouterOutputs = inferRouterOutputs<AppRouter>; export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function handleError(error: TRPCClientErrorBase<any>) { export function handleError(error: TRPCClientErrorBase<any>) {
toast({ toast({
title: 'Error', title: 'Error',
description: error.message, description: error.message,
}) });
} }

View File

@@ -1,9 +1,9 @@
import { toast } from "@/components/ui/use-toast" import { toast } from '@/components/ui/use-toast';
export function clipboard(value: string | number) { export function clipboard(value: string | number) {
navigator.clipboard.writeText(value.toString()) navigator.clipboard.writeText(value.toString());
toast({ toast({
title: "Copied to clipboard", title: 'Copied to clipboard',
description: value.toString(), description: value.toString(),
}) });
} }

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from 'clsx';
import { twMerge } from "tailwind-merge" import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View File

@@ -1,47 +1,47 @@
export const operators = { export const operators = {
is: "Is", is: 'Is',
isNot: "Is not", isNot: 'Is not',
contains: "Contains", contains: 'Contains',
doesNotContain: "Not contains", doesNotContain: 'Not contains',
}; };
export const chartTypes = { export const chartTypes = {
linear: "Linear", linear: 'Linear',
bar: "Bar", bar: 'Bar',
pie: "Pie", pie: 'Pie',
metric: "Metric", metric: 'Metric',
area: "Area", area: 'Area',
}; };
export const intervals = { export const intervals = {
minute: "Minute", minute: 'Minute',
day: "Day", day: 'Day',
hour: "Hour", hour: 'Hour',
month: "Month", month: 'Month',
}; };
export const alphabetIds = [ export const alphabetIds = [
"A", 'A',
"B", 'B',
"C", 'C',
"D", 'D',
"E", 'E',
"F", 'F',
"G", 'G',
"H", 'H',
"I", 'I',
"J", 'J',
] as const; ] as const;
export const timeRanges = [ export const timeRanges = [
{ range: 0.3, title: "30m" }, { range: 0.3, title: '30m' },
{ range: 0.6, title: "1h" }, { range: 0.6, title: '1h' },
{ range: 0, title: "Today" }, { range: 0, title: 'Today' },
{ range: 1, title: "24h" }, { range: 1, title: '24h' },
{ range: 7, title: "7d" }, { range: 7, title: '7d' },
{ range: 14, title: "14d" }, { range: 14, title: '14d' },
{ range: 30, title: "30d" }, { range: 30, title: '30d' },
{ range: 90, title: "3mo" }, { range: 90, title: '3mo' },
{ range: 180, title: "6mo" }, { range: 180, title: '6mo' },
{ range: 365, title: "1y" }, { range: 365, title: '1y' },
] as const; ] as const;

View File

@@ -10,11 +10,11 @@ export function dateDifferanceInDays(date1: Date, date2: Date) {
} }
export function getLocale() { export function getLocale() {
if (typeof navigator === "undefined") { if (typeof navigator === 'undefined') {
return "en-US"; return 'en-US';
} }
return navigator.language ?? "en-US"; return navigator.language ?? 'en-US';
} }
export function formatDate(date: Date) { export function formatDate(date: Date) {
@@ -23,10 +23,10 @@ export function formatDate(date: Date) {
export function formatDateTime(date: Date) { export function formatDateTime(date: Date) {
return new Intl.DateTimeFormat(getLocale(), { return new Intl.DateTimeFormat(getLocale(), {
day: "numeric", day: 'numeric',
month: "numeric", month: 'numeric',
year: "numeric", year: 'numeric',
hour: "numeric", hour: 'numeric',
minute: "numeric", minute: 'numeric',
}).format(date); }).format(date);
} }

View File

@@ -1,8 +1,7 @@
import { type IChartRange } from "@/types"; import { type IChartRange } from '@/types';
import { timeRanges } from "./constants";
import { timeRanges } from './constants';
export function getRangeLabel(range: IChartRange) { export function getRangeLabel(range: IChartRange) {
return timeRanges.find( return timeRanges.find((item) => item.range === range)?.title ?? null;
(item) => item.range === range, }
)?.title ?? null
}

View File

@@ -1,9 +1,9 @@
export function toDots( export function toDots(
obj: Record<string, unknown>, obj: Record<string, unknown>,
path = "", path = ''
): Record<string, number | string | boolean> { ): Record<string, number | string | boolean> {
return Object.entries(obj).reduce((acc, [key, value]) => { return Object.entries(obj).reduce((acc, [key, value]) => {
if (typeof value === "object" && value !== null) { if (typeof value === 'object' && value !== null) {
return { return {
...acc, ...acc,
...toDots(value as Record<string, unknown>, `${path}${key}.`), ...toDots(value as Record<string, unknown>, `${path}${key}.`),
@@ -15,4 +15,4 @@ export function toDots(
[`${path}${key}`]: value, [`${path}${key}`]: value,
}; };
}, {}); }, {});
} }

View File

@@ -1,4 +1,4 @@
import _slugify from 'slugify' import _slugify from 'slugify';
const slugify = (str: string) => { const slugify = (str: string) => {
return _slugify( return _slugify(
@@ -9,10 +9,10 @@ const slugify = (str: string) => {
.replace('Å', 'A') .replace('Å', 'A')
.replace('Ä', 'A') .replace('Ä', 'A')
.replace('Ö', 'O'), .replace('Ö', 'O'),
{ lower: true, strict: true, trim: true }, { lower: true, strict: true, trim: true }
) );
} };
export function slug(str: string): string { export function slug(str: string): string {
return slugify(str) return slugify(str);
} }

View File

@@ -1,5 +1,7 @@
import resolveConfig from "tailwindcss/resolveConfig"; import resolveConfig from 'tailwindcss/resolveConfig';
import tailwinConfig from "../../tailwind.config";
import tailwinConfig from '../../tailwind.config';
// @ts-expect-error // @ts-expect-error
const config = resolveConfig(tailwinConfig); const config = resolveConfig(tailwinConfig);
@@ -7,7 +9,7 @@ export const theme = config.theme as any;
export function getChartColor(index: number): string { export function getChartColor(index: number): string {
const chartColors: string[] = Object.keys(theme?.colors ?? {}) const chartColors: string[] = Object.keys(theme?.colors ?? {})
.filter((key) => key.startsWith("chart-")) .filter((key) => key.startsWith('chart-'))
.map((key) => theme.colors[key] as string); .map((key) => theme.colors[key] as string);
return chartColors[index % chartColors.length]!; return chartColors[index % chartColors.length]!;

View File

@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from 'zod';
import { operators, chartTypes, intervals } from "./constants";
import { chartTypes, intervals, operators } from './constants';
function objectToZodEnums<K extends string>(obj: Record<K, any>): [K, ...K[]] { function objectToZodEnums<K extends string>(obj: Record<K, any>): [K, ...K[]] {
const [firstKey, ...otherKeys] = Object.keys(obj) as K[]; const [firstKey, ...otherKeys] = Object.keys(obj) as K[];
@@ -9,14 +10,14 @@ function objectToZodEnums<K extends string>(obj: Record<K, any>): [K, ...K[]] {
export const zChartEvent = z.object({ export const zChartEvent = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
segment: z.enum(["event", "user"]), segment: z.enum(['event', 'user']),
filters: z.array( filters: z.array(
z.object({ z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
operator: z.enum(objectToZodEnums(operators)), operator: z.enum(objectToZodEnums(operators)),
value: z.array(z.string().or(z.number()).or(z.boolean()).or(z.null())), value: z.array(z.string().or(z.number()).or(z.boolean()).or(z.null())),
}), })
), ),
}); });
export const zChartBreakdown = z.object({ export const zChartBreakdown = z.object({

View File

@@ -1,72 +1,70 @@
const colors = [ const colors = [
"#7856ff", '#7856ff',
"#ff7557", '#ff7557',
"#7fe1d8", '#7fe1d8',
"#f8bc3c", '#f8bc3c',
"#b3596e", '#b3596e',
"#72bef4", '#72bef4',
"#ffb27a", '#ffb27a',
"#0f7ea0", '#0f7ea0',
"#3ba974", '#3ba974',
"#febbb2", '#febbb2',
"#cb80dc", '#cb80dc',
"#5cb7af", '#5cb7af',
]; ];
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
const config = { const config = {
safelist: [ safelist: [...colors.map((color) => `chart-${color}`)],
...colors.map((color) => `chart-${color}`), darkMode: ['class'],
],
darkMode: ["class"],
content: [ content: [
"./pages/**/*.{ts,tsx}", './pages/**/*.{ts,tsx}',
"./components/**/*.{ts,tsx}", './components/**/*.{ts,tsx}',
"./app/**/*.{ts,tsx}", './app/**/*.{ts,tsx}',
"./src/**/*.{ts,tsx}", './src/**/*.{ts,tsx}',
], ],
theme: { theme: {
container: { container: {
center: true, center: true,
padding: "2rem", padding: '2rem',
screens: { screens: {
"2xl": "1400px", '2xl': '1400px',
}, },
}, },
extend: { extend: {
colors: { colors: {
border: "hsl(var(--border))", border: 'hsl(var(--border))',
input: "hsl(var(--input))", input: 'hsl(var(--input))',
ring: "hsl(var(--ring))", ring: 'hsl(var(--ring))',
background: "hsl(var(--background))", background: 'hsl(var(--background))',
foreground: "hsl(var(--foreground))", foreground: 'hsl(var(--foreground))',
primary: { primary: {
DEFAULT: "hsl(var(--primary))", DEFAULT: 'hsl(var(--primary))',
foreground: "hsl(var(--primary-foreground))", foreground: 'hsl(var(--primary-foreground))',
}, },
secondary: { secondary: {
DEFAULT: "hsl(var(--secondary))", DEFAULT: 'hsl(var(--secondary))',
foreground: "hsl(var(--secondary-foreground))", foreground: 'hsl(var(--secondary-foreground))',
}, },
destructive: { destructive: {
DEFAULT: "hsl(var(--destructive))", DEFAULT: 'hsl(var(--destructive))',
foreground: "hsl(var(--destructive-foreground))", foreground: 'hsl(var(--destructive-foreground))',
}, },
muted: { muted: {
DEFAULT: "hsl(var(--muted))", DEFAULT: 'hsl(var(--muted))',
foreground: "hsl(var(--muted-foreground))", foreground: 'hsl(var(--muted-foreground))',
}, },
accent: { accent: {
DEFAULT: "hsl(var(--accent))", DEFAULT: 'hsl(var(--accent))',
foreground: "hsl(var(--accent-foreground))", foreground: 'hsl(var(--accent-foreground))',
}, },
popover: { popover: {
DEFAULT: "hsl(var(--popover))", DEFAULT: 'hsl(var(--popover))',
foreground: "hsl(var(--popover-foreground))", foreground: 'hsl(var(--popover-foreground))',
}, },
card: { card: {
DEFAULT: "hsl(var(--card))", DEFAULT: 'hsl(var(--card))',
foreground: "hsl(var(--card-foreground))", foreground: 'hsl(var(--card-foreground))',
}, },
...colors.reduce((acc, color, index) => { ...colors.reduce((acc, color, index) => {
return { return {
@@ -76,30 +74,30 @@ const config = {
}, {}), }, {}),
}, },
borderRadius: { borderRadius: {
lg: "var(--radius)", lg: 'var(--radius)',
md: "calc(var(--radius) - 2px)", md: 'calc(var(--radius) - 2px)',
sm: "calc(var(--radius) - 4px)", sm: 'calc(var(--radius) - 4px)',
}, },
boxShadow: { boxShadow: {
DEFAULT: '0 5px 10px rgb(0 0 0 / 5%)' DEFAULT: '0 5px 10px rgb(0 0 0 / 5%)',
}, },
keyframes: { keyframes: {
"accordion-down": { 'accordion-down': {
from: { height: 0 }, from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" }, to: { height: 'var(--radix-accordion-content-height)' },
}, },
"accordion-up": { 'accordion-up': {
from: { height: "var(--radix-accordion-content-height)" }, from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 }, to: { height: 0 },
}, },
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", 'accordion-down': 'accordion-down 0.2s ease-out',
"accordion-up": "accordion-up 0.2s ease-out", 'accordion-up': 'accordion-up 0.2s ease-out',
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require('tailwindcss-animate')],
}; };
export default config export default config;

View File

@@ -1,19 +1,25 @@
{ {
"name": "@mixan/root", "name": "@mixan/root",
"version": "1.0.0", "version": "1.0.0",
"keywords": [], "private": true,
"author": "", "license": "MIT",
"author": "Carl-Gerhard Lindesvärd",
"packageManager": "pnpm@8.7.6", "packageManager": "pnpm@8.7.6",
"license": "ISC",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "pnpm -r dev" "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\"",
"lint": "pnpm -r lint",
"lint:fix": "pnpm -r lint --fix",
"lint:workspace": "pnpm dlx sherif@latest",
"typecheck": "pnpm -r typecheck"
}, },
"devDependencies": { "devDependencies": {
"semver": "^7.5.4" "semver": "^7.5.4"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.0.0" "typescript": "^5.2.0"
} }
} }

View File

@@ -1,43 +1,43 @@
import { import type {
EventPayload, EventPayload,
MixanErrorResponse, MixanErrorResponse,
ProfilePayload, ProfilePayload,
} from '@mixan/types' } from '@mixan/types';
type NewMixanOptions = { interface NewMixanOptions {
url: string url: string;
clientId: string clientId: string;
clientSecret: string clientSecret: string;
batchInterval?: number batchInterval?: number;
maxBatchSize?: number maxBatchSize?: number;
sessionTimeout?: number sessionTimeout?: number;
verbose?: boolean verbose?: boolean;
saveProfileId: (profiId: string) => void saveProfileId: (profiId: string) => void;
getProfileId: () => (string | null) getProfileId: () => string | null;
removeProfileId: () => void removeProfileId: () => void;
} }
type MixanOptions = Required<NewMixanOptions> type MixanOptions = Required<NewMixanOptions>;
class Fetcher { class Fetcher {
private url: string private url: string;
private clientId: string private clientId: string;
private clientSecret: string private clientSecret: string;
private logger: (...args: any[]) => void private logger: (...args: any[]) => void;
constructor(options: MixanOptions) { constructor(options: MixanOptions) {
this.url = options.url this.url = options.url;
this.clientId = options.clientId this.clientId = options.clientId;
this.clientSecret = options.clientSecret this.clientSecret = options.clientSecret;
this.logger = options.verbose ? console.log : () => {} this.logger = options.verbose ? console.log : () => {};
} }
post<Response extends unknown>( post<PostData, PostResponse>(
path: string, path: string,
data: Record<string, any> = {}, data?: PostData,
options: RequestInit = {} options?: RequestInit
): Promise<Response | null> { ): Promise<PostResponse | null> {
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, {
headers: { headers: {
['mixan-client-id']: this.clientId, ['mixan-client-id']: this.clientId,
@@ -45,103 +45,109 @@ class Fetcher {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data ?? {}),
...options, ...(options ?? {}),
}) })
.then(async (res) => { .then(async (res) => {
const response = await res.json() as (MixanErrorResponse | Response) const response = (await res.json()) as
| MixanErrorResponse
| PostResponse;
if(!response) { if (!response) {
return null return null;
} }
if (typeof response === 'object' && 'status' in response && response.status === 'error') { if (
typeof response === 'object' &&
'status' in response &&
response.status === 'error'
) {
this.logger( this.logger(
`Mixan request failed: [${options.method || 'POST'}] ${url}`, `Mixan request failed: [${options?.method ?? 'POST'}] ${url}`,
JSON.stringify(response, null, 2) JSON.stringify(response, null, 2)
) );
return null return null;
} }
return response as Response return response as PostResponse;
}) })
.catch(() => { .catch(() => {
this.logger( this.logger(
`Mixan request failed: [${options.method || 'POST'}] ${url}` `Mixan request failed: [${options?.method ?? 'POST'}] ${url}`
) );
return null return null;
}) });
} }
} }
class Batcher<T extends any> { class Batcher<T> {
queue: T[] = [] queue: T[] = [];
timer?: NodeJS.Timeout timer?: NodeJS.Timeout;
callback: (queue: T[]) => void callback: (queue: T[]) => void;
maxBatchSize: number maxBatchSize: number;
batchInterval: number batchInterval: number;
constructor(options: MixanOptions, callback: (queue: T[]) => void) { constructor(options: MixanOptions, callback: (queue: T[]) => void) {
this.callback = callback this.callback = callback;
this.maxBatchSize = options.maxBatchSize this.maxBatchSize = options.maxBatchSize;
this.batchInterval = options.batchInterval this.batchInterval = options.batchInterval;
} }
add(payload: T) { add(payload: T) {
this.queue.push(payload) this.queue.push(payload);
this.flush() this.flush();
} }
flush() { flush() {
if (this.timer) { if (this.timer) {
clearTimeout(this.timer) clearTimeout(this.timer);
} }
if (this.queue.length === 0) { if (this.queue.length === 0) {
return return;
} }
if (this.queue.length >= this.maxBatchSize) { if (this.queue.length >= this.maxBatchSize) {
this.send() this.send();
return return;
} }
this.timer = setTimeout(this.send.bind(this), this.batchInterval) this.timer = setTimeout(this.send.bind(this), this.batchInterval);
} }
send() { send() {
if (this.timer) { if (this.timer) {
clearTimeout(this.timer) clearTimeout(this.timer);
} }
if (this.queue.length > 0) { if (this.queue.length > 0) {
this.callback(this.queue) this.callback(this.queue);
this.queue = [] this.queue = [];
} }
} }
} }
export class Mixan { export class Mixan {
private fetch: Fetcher private fetch: Fetcher;
private eventBatcher: Batcher<EventPayload> private eventBatcher: Batcher<EventPayload>;
private profileId?: string private profileId?: string;
private options: MixanOptions private options: MixanOptions;
private logger: (...args: any[]) => void private logger: (...args: any[]) => void;
private globalProperties: Record<string, any> = {} private globalProperties: Record<string, unknown> = {};
private lastEventAt?: string private lastEventAt?: string;
private lastScreenViewAt?: string private lastScreenViewAt?: string;
constructor(options: NewMixanOptions) { constructor(options: NewMixanOptions) {
this.logger = options.verbose ? console.log : () => {} this.logger = options.verbose ? console.log : () => {};
this.options = { this.options = {
sessionTimeout: 1000 * 60 * 30, sessionTimeout: 1000 * 60 * 30,
verbose: false, verbose: false,
batchInterval: 10000, batchInterval: 10000,
maxBatchSize: 10, maxBatchSize: 10,
...options, ...options,
} };
this.fetch = new Fetcher(this.options) this.fetch = new Fetcher(this.options);
this.eventBatcher = new Batcher(this.options, (queue) => { this.eventBatcher = new Batcher(this.options, (queue) => {
this.fetch.post( this.fetch.post(
'/events', '/events',
@@ -151,103 +157,106 @@ export class Mixan {
...this.globalProperties, ...this.globalProperties,
...item.properties, ...item.properties,
}, },
profileId: item.profileId || this.profileId || null, profileId: item.profileId ?? this.profileId ?? null,
})) }))
) );
}) });
} }
timestamp() { timestamp() {
return new Date().toISOString() return new Date().toISOString();
} }
init() { init() {
this.logger('Mixan: Init') this.logger('Mixan: Init');
this.setAnonymousUser() this.setAnonymousUser();
} }
event(name: string, properties: Record<string, any> = {}) { event(name: string, properties: Record<string, unknown> = {}) {
const now = new Date() const now = new Date();
const isSessionStart = const isSessionStart =
now.getTime() - new Date(this.lastEventAt ?? '1970-01-01').getTime() > now.getTime() - new Date(this.lastEventAt ?? '1970-01-01').getTime() >
this.options.sessionTimeout this.options.sessionTimeout;
if (isSessionStart) { if (isSessionStart) {
this.logger('Mixan: Session start') this.logger('Mixan: Session start');
this.eventBatcher.add({ this.eventBatcher.add({
name: 'session_start', name: 'session_start',
time: this.timestamp(), time: this.timestamp(),
properties: {}, properties: {},
profileId: this.profileId || null, profileId: this.profileId ?? null,
}) });
} }
this.logger('Mixan: Queue event', name) this.logger('Mixan: Queue event', name);
this.eventBatcher.add({ this.eventBatcher.add({
name, name,
properties, properties,
time: this.timestamp(), time: this.timestamp(),
profileId: this.profileId || null, profileId: this.profileId ?? null,
}) });
this.lastEventAt = this.timestamp() this.lastEventAt = this.timestamp();
} }
private async setAnonymousUser(retryCount: number = 0) { private async setAnonymousUser(retryCount = 0) {
const profileId = this.options.getProfileId() const profileId = this.options.getProfileId();
if (profileId) { if (profileId) {
this.profileId = profileId this.profileId = profileId;
this.logger('Mixan: Use existing profile', this.profileId) this.logger('Mixan: Use existing profile', this.profileId);
} else { } else {
const res = await this.fetch.post<{id: string}>('/profiles') const res = await this.fetch.post<undefined, { id: string }>('/profiles');
if(res) { if (res) {
this.profileId = res.id this.profileId = res.id;
this.options.saveProfileId(res.id) this.options.saveProfileId(res.id);
this.logger('Mixan: Create new profile', this.profileId) this.logger('Mixan: Create new profile', this.profileId);
} else if(retryCount < 2) { } else if (retryCount < 2) {
setTimeout(() => { setTimeout(() => {
this.setAnonymousUser(retryCount + 1) this.setAnonymousUser(retryCount + 1);
}, 500); }, 500);
} else { } else {
this.logger('Mixan: Failed to create new profile') this.logger('Mixan: Failed to create new profile');
} }
} }
} }
async setUser(profile: ProfilePayload) { async setUser(profile: ProfilePayload) {
if (!this.profileId) { if (!this.profileId) {
return this.logger('Mixan: Set user failed, no profileId') return this.logger('Mixan: Set user failed, no profileId');
} }
this.logger('Mixan: Set user', profile) this.logger('Mixan: Set user', profile);
await this.fetch.post(`/profiles/${this.profileId}`, profile, { await this.fetch.post(`/profiles/${this.profileId}`, profile, {
method: 'PUT', method: 'PUT',
}) });
} }
async setUserProperty(name: string, value: any) { async setUserProperty(
name: string,
value: string | number | boolean | Record<string, unknown> | unknown[]
) {
if (!this.profileId) { if (!this.profileId) {
return this.logger('Mixan: Set user property, no profileId') return this.logger('Mixan: Set user property, no profileId');
} }
this.logger('Mixan: Set user property', name, value) this.logger('Mixan: Set user property', name, value);
await this.fetch.post(`/profiles/${this.profileId}`, { await this.fetch.post(`/profiles/${this.profileId}`, {
properties: { properties: {
[name]: value, [name]: value,
}, },
}) });
} }
async setGlobalProperties(properties: Record<string, any>) { setGlobalProperties(properties: Record<string, unknown>) {
this.logger('Mixan: Set global properties', properties) this.logger('Mixan: Set global properties', properties);
this.globalProperties = properties ?? {} this.globalProperties = properties ?? {};
} }
async increment(name: string, value: number = 1) { async increment(name: string, value = 1) {
if (!this.profileId) { if (!this.profileId) {
this.logger('Mixan: Increment failed, no profileId') this.logger('Mixan: Increment failed, no profileId');
return return;
} }
this.logger('Mixan: Increment user property', name, value) this.logger('Mixan: Increment user property', name, value);
await this.fetch.post( await this.fetch.post(
`/profiles/${this.profileId}/increment`, `/profiles/${this.profileId}/increment`,
{ {
@@ -257,16 +266,16 @@ export class Mixan {
{ {
method: 'PUT', method: 'PUT',
} }
) );
} }
async decrement(name: string, value: number = 1) { async decrement(name: string, value = 1) {
if (!this.profileId) { if (!this.profileId) {
this.logger('Mixan: Decrement failed, no profileId') this.logger('Mixan: Decrement failed, no profileId');
return return;
} }
this.logger('Mixan: Decrement user property', name, value) this.logger('Mixan: Decrement user property', name, value);
await this.fetch.post( await this.fetch.post(
`/profiles/${this.profileId}/decrement`, `/profiles/${this.profileId}/decrement`,
{ {
@@ -276,38 +285,38 @@ export class Mixan {
{ {
method: 'PUT', method: 'PUT',
} }
) );
} }
async screenView(route: string, _properties?: Record<string, any>) { screenView(route: string, _properties?: Record<string, unknown>) {
const properties = _properties ?? {} const properties = _properties ?? {};
const now = new Date() const now = new Date();
if (this.lastScreenViewAt) { if (this.lastScreenViewAt) {
const last = new Date(this.lastScreenViewAt) const last = new Date(this.lastScreenViewAt);
const diff = now.getTime() - last.getTime() const diff = now.getTime() - last.getTime();
this.logger(`Mixan: Screen view duration: ${diff}ms`) this.logger(`Mixan: Screen view duration: ${diff}ms`);
properties['duration'] = diff properties.duration = diff;
} }
this.lastScreenViewAt = now.toISOString() this.lastScreenViewAt = now.toISOString();
await this.event('screen_view', { this.event('screen_view', {
...properties, ...properties,
route, route,
}) });
} }
flush() { flush() {
this.logger('Mixan: Flushing events queue') this.logger('Mixan: Flushing events queue');
this.eventBatcher.send() this.eventBatcher.send();
this.lastScreenViewAt = undefined this.lastScreenViewAt = undefined;
} }
clear() { clear() {
this.logger('Mixan: Clear, send remaining events and remove profileId') this.logger('Mixan: Clear, send remaining events and remove profileId');
this.eventBatcher.send() this.eventBatcher.send();
this.options.removeProfileId() this.options.removeProfileId();
this.profileId = undefined this.profileId = undefined;
this.setAnonymousUser() this.setAnonymousUser();
} }
} }

View File

@@ -3,12 +3,25 @@
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"module": "index.ts", "module": "index.ts",
"scripts": {
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": { "dependencies": {
"@mixan/types": "workspace:*" "@mixan/types": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/uuid": "^9.0.5", "@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"tsup": "^7.2.0", "tsup": "^7.2.0",
"typescript": "^5.0.0" "typescript": "^5.2.0"
} },
} "eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

View File

@@ -13,9 +13,9 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"allowJs": true, "allowJs": true,
"outDir": "dist", "outDir": "dist",
"allowImportingTsExtensions": false, "allowImportingTsExtensions": false,
"noEmit": false, "noEmit": false
} }
} }

View File

@@ -1,8 +1,8 @@
import { defineConfig } from "tsup"; import { defineConfig } from 'tsup';
export default defineConfig({ export default defineConfig({
entry: ["index.ts"], entry: ['index.ts'],
format: ["cjs", "esm"], // Build for commonJS and ESmodules format: ['cjs', 'esm'], // Build for commonJS and ESmodules
dts: true, // Generate declaration file (.d.ts) dts: true, // Generate declaration file (.d.ts)
splitting: false, splitting: false,
sourcemap: true, sourcemap: true,

View File

@@ -1,76 +1,76 @@
export type MixanJson = Record<string, any> export type MixanJson = Record<string, any>;
export type EventPayload = { export interface EventPayload {
name: string name: string;
time: string time: string;
profileId: string | null profileId: string | null;
properties: MixanJson properties: MixanJson;
} }
export type ProfilePayload = { export interface ProfilePayload {
first_name?: string first_name?: string;
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 interface ProfileIncrementPayload {
name: string name: string;
value: number value: number;
id: string id: string;
} }
export type ProfileDecrementPayload = { export interface ProfileDecrementPayload {
name: string name: string;
value: number value: number;
id: string id: string;
} }
// Batching // Batching
export type BatchEvent = { export interface BatchEvent {
type: 'event' type: 'event';
payload: EventPayload payload: EventPayload;
} }
export type BatchProfile = { export interface BatchProfile {
type: 'profile' type: 'profile';
payload: ProfilePayload payload: ProfilePayload;
} }
export type BatchProfileIncrement = { export interface BatchProfileIncrement {
type: 'profile_increment' type: 'profile_increment';
payload: ProfileIncrementPayload payload: ProfileIncrementPayload;
} }
export type BatchProfileDecrement = { export interface BatchProfileDecrement {
type: 'profile_decrement' type: 'profile_decrement';
payload: ProfileDecrementPayload payload: ProfileDecrementPayload;
} }
export type BatchItem = export type BatchItem =
| BatchEvent | BatchEvent
| BatchProfile | BatchProfile
| BatchProfileIncrement | BatchProfileIncrement
| BatchProfileDecrement | BatchProfileDecrement;
export type BatchPayload = Array<BatchItem> export type BatchPayload = BatchItem[];
export type MixanIssue = { export interface MixanIssue {
field: string field: string;
message: string message: string;
value: any value: any;
} }
export type MixanErrorResponse = { export interface MixanErrorResponse {
status: 'error' status: 'error';
code: number code: number;
message: string message: string;
issues?: Array<MixanIssue> | undefined issues?: MixanIssue[] | undefined;
stack?: string | undefined stack?: string | undefined;
} }
export type MixanResponse<T> = { export interface MixanResponse<T> {
result: T result: T;
status: 'ok' status: 'ok';
} }

View File

@@ -3,8 +3,22 @@
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"module": "index.ts", "module": "index.ts",
"scripts": {
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"devDependencies": { "devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"tsup": "^7.2.0", "tsup": "^7.2.0",
"typescript": "^5.0.0" "typescript": "^5.2.0"
} },
} "eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

View File

@@ -1,8 +1,8 @@
import { defineConfig } from "tsup"; import { defineConfig } from 'tsup';
export default defineConfig({ export default defineConfig({
entry: ["index.ts"], entry: ['index.ts'],
format: ["cjs", "esm"], // Build for commonJS and ESmodules format: ['cjs', 'esm'], // Build for commonJS and ESmodules
dts: true, // Generate declaration file (.d.ts) dts: true, // Generate declaration file (.d.ts)
splitting: false, splitting: false,
sourcemap: false, sourcemap: false,

687
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
packages: packages:
- 'apps/*' - 'apps/*'
- 'packages/*' - 'packages/*'
- 'tooling/*'

View File

@@ -1,7 +1,7 @@
import sdkPkg from './packages/sdk/package.json' import sdkPkg from './packages/sdk/package.json'
import typesPkg from './packages/types/package.json' import typesPkg from './packages/types/package.json'
import fs from 'node:fs' import fs from 'node:fs'
import {execSync} from 'node:child_process' import { execSync } from 'node:child_process'
import semver from 'semver' import semver from 'semver'
function savePackageJson(path: string, data: Record<string, any>) { function savePackageJson(path: string, data: Record<string, any>) {
@@ -10,15 +10,15 @@ function savePackageJson(path: string, data: Record<string, any>) {
function main() { function main() {
const [version] = process.argv.slice(2) const [version] = process.argv.slice(2)
if(!version) { if (!version) {
return console.error('Missing version') return console.error('Missing version')
} }
if(!semver.valid(version)) { if (!semver.valid(version)) {
return console.error('Version is not valid') return console.error('Version is not valid')
} }
const properties = { const properties = {
private: false, private: false,
version, version,
@@ -53,12 +53,12 @@ function main() {
execSync('pnpm dlx tsup', { execSync('pnpm dlx tsup', {
cwd: './packages/types', cwd: './packages/types',
}) })
} catch(error) { } catch (error) {
console.log('Build failed'); console.log('Build failed')
console.log(error); console.log(error)
process.exit(1) process.exit(1)
} }
execSync('npm publish --access=public', { execSync('npm publish --access=public', {
cwd: './packages/sdk', cwd: './packages/sdk',
}) })

50
tooling/eslint/base.js Normal file
View File

@@ -0,0 +1,50 @@
/** @type {import("eslint").Linter.Config} */
const config = {
extends: [
'turbo',
'eslint:recommended',
'plugin:@typescript-eslint/recommended-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'prettier',
],
env: {
es2022: true,
node: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
project: true,
},
plugins: ['@typescript-eslint', 'import'],
rules: {
'turbo/no-undeclared-env-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' },
],
'@typescript-eslint/no-misused-promises': [
2,
{ checksVoidReturn: { attributes: false } },
],
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/ban-ts-comment": "off"
},
ignorePatterns: [
'**/.eslintrc.cjs',
'**/*.config.js',
'**/*.config.cjs',
'.next',
'dist',
'pnpm-lock.yaml',
],
reportUnusedDisableDirectives: true,
};
module.exports = config;

View File

@@ -0,0 +1,40 @@
{
"name": "@mixan/eslint-config",
"version": "0.1.0",
"private": true,
"license": "MIT",
"files": [
"./base.js",
"./react.js"
],
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-turbo": "^1.10.13",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0"
},
"devDependencies": {
"@types/eslint": "^8.44.2",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"eslint": "^8.48.0",
"typescript": "^5.2.0"
},
"eslintConfig": {
"root": true,
"extends": [
"./base.js"
]
},
"prettier": "@mixan/prettier-config"
}

24
tooling/eslint/react.js vendored Normal file
View File

@@ -0,0 +1,24 @@
/** @type {import('eslint').Linter.Config} */
const config = {
extends: [
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
],
rules: {
'react/prop-types': 'off',
},
globals: {
React: 'writable',
},
settings: {
react: {
version: 'detect',
},
},
env: {
browser: true,
},
};
module.exports = config;

View File

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

View File

@@ -0,0 +1,28 @@
/** @typedef {import("prettier").Config} PrettierConfig */
/** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */
/** @type { PrettierConfig | SortImportsConfig } */
const config = {
plugins: [
'@ianvs/prettier-plugin-sort-imports',
],
importOrder: [
'^(react/(.*)$)|^(react$)|^(react-native(.*)$)',
'<THIRD_PARTY_MODULES>',
'',
'^@mixan/(.*)$',
'',
'^~/',
'^[../]',
'^[./]',
],
importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'],
importOrderTypeScriptVersion: '4.4.0',
singleQuote: true,
semi: true,
trailingComma: 'es5',
printWidth: 80,
tabWidth: 2,
};
export default config;

View File

@@ -0,0 +1,17 @@
{
"name": "@mixan/prettier-config",
"version": "0.1.0",
"private": true,
"main": "index.mjs",
"scripts": {
"clean": "rm -rf .turbo node_modules"
},
"dependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.1.0",
"prettier": "^3.0.3"
},
"devDependencies": {
"@mixan/tsconfig": "workspace:*",
"typescript": "^5.2.0"
}
}

View File

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

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"checkJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"noUncheckedIndexedAccess": true
},
"exclude": ["node_modules", "build", "dist"]
}

View File

@@ -0,0 +1,8 @@
{
"name": "@mixan/tsconfig",
"version": "0.1.0",
"private": true,
"files": [
"base.json"
]
}