diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 00000000..c1464a2f --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,25 @@ +# Since the ".env" file is gitignored, you can use the ".env.example" file to +# build a new ".env" file when you clone the repo. Keep this file up-to-date +# when you add new variables to `.env`. + +# This file will be committed to version control, so make sure not to have any +# secrets in it. If you are cloning this repo, create a copy of this file named +# ".env" and populate it with your secrets. + +# When adding additional environment variables, the schema in "/src/env.mjs" +# should be updated accordingly. + +# Prisma +# https://www.prisma.io/docs/reference/database-reference/connection-urls#env +DATABASE_URL="file:./db.sqlite" + +# Next Auth +# You can generate a new secret on the command line with: +# openssl rand -base64 32 +# https://next-auth.js.org/configuration/options#secret +# NEXTAUTH_SECRET="" +NEXTAUTH_URL="http://localhost:3000" + +# Next Auth Discord Provider +DISCORD_CLIENT_ID="" +DISCORD_CLIENT_SECRET="" diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs new file mode 100644 index 00000000..f15a4d58 --- /dev/null +++ b/apps/web/.eslintrc.cjs @@ -0,0 +1,30 @@ +/** @type {import("eslint").Linter.Config} */ +const config = { + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, + plugins: ["@typescript-eslint"], + extends: [ + "next/core-web-vitals", + "plugin:@typescript-eslint/recommended-type-checked", + "plugin:@typescript-eslint/stylistic-type-checked", + ], + rules: { + // These opinionated rules are enabled in stylistic-type-checked above. + // Feel free to reconfigure them to your own preference. + "@typescript-eslint/array-type": "off", + "@typescript-eslint/consistent-type-definitions": "off", + + "@typescript-eslint/consistent-type-imports": [ + "warn", + { + prefer: "type-imports", + fixStyle: "inline-type-imports", + }, + ], + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + }, +}; + +module.exports = config; diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 00000000..2971a0bd --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# database +/prisma/db.sqlite +/prisma/db.sqlite-journal + +# next.js +/.next/ +/out/ +next-env.d.ts + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 00000000..fba19eda --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,28 @@ +# Create T3 App + +This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. + +## What's next? How do I make an app with this? + +We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. + +If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. + +- [Next.js](https://nextjs.org) +- [NextAuth.js](https://next-auth.js.org) +- [Prisma](https://prisma.io) +- [Tailwind CSS](https://tailwindcss.com) +- [tRPC](https://trpc.io) + +## Learn More + +To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: + +- [Documentation](https://create.t3.gg/) +- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials + +You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome! + +## How do I deploy this? + +Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 00000000..61964ea7 --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,22 @@ +/** + * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful + * for Docker builds. + */ +await import("./src/env.mjs"); + +/** @type {import("next").NextConfig} */ +const config = { + reactStrictMode: true, + + /** + * If you are using `appDir` then you must comment the below `i18n` config out. + * + * @see https://github.com/vercel/next.js/issues/41980 + */ + i18n: { + locales: ["en"], + defaultLocale: "en", + }, +}; + +export default config; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..de4ba97a --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,54 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build", + "db:push": "prisma db push", + "dev": "next dev", + "postinstall": "prisma generate", + "lint": "next lint", + "start": "next start" + }, + "dependencies": { + "@mixan/types": "^0.0.2-alpha", + "@next-auth/prisma-adapter": "^1.0.7", + "@prisma/client": "^5.1.1", + "@t3-oss/env-nextjs": "^0.7.0", + "@tanstack/react-query": "^4.32.6", + "@trpc/client": "^10.37.1", + "@trpc/next": "^10.37.1", + "@trpc/react-query": "^10.37.1", + "@trpc/server": "^10.37.1", + "bcrypt": "^5.1.1", + "next": "^13.5.4", + "next-auth": "^4.23.0", + "random-animal-name": "^0.1.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "superjson": "^1.13.1", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.0", + "@types/eslint": "^8.44.2", + "@types/node": "^18.16.0", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.3.0", + "@typescript-eslint/parser": "^6.3.0", + "autoprefixer": "^10.4.14", + "eslint": "^8.47.0", + "eslint-config-next": "^13.5.4", + "postcss": "^8.4.27", + "prettier": "^3.0.0", + "prettier-plugin-tailwindcss": "^0.5.1", + "prisma": "^5.1.1", + "tailwindcss": "^3.3.3", + "typescript": "^5.1.6" + }, + "ct3aMetadata": { + "initVersion": "7.21.0" + }, + "packageManager": "npm@9.8.1" +} diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs new file mode 100644 index 00000000..e305dd92 --- /dev/null +++ b/apps/web/postcss.config.cjs @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +module.exports = config; diff --git a/apps/web/prettier.config.mjs b/apps/web/prettier.config.mjs new file mode 100644 index 00000000..2d2fa4c9 --- /dev/null +++ b/apps/web/prettier.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').options} */ +const config = { + plugins: ["prettier-plugin-tailwindcss"], +}; + +export default config; diff --git a/apps/web/prisma/migrations/20231010091416_init/migration.sql b/apps/web/prisma/migrations/20231010091416_init/migration.sql new file mode 100644 index 00000000..3fb3b5d4 --- /dev/null +++ b/apps/web/prisma/migrations/20231010091416_init/migration.sql @@ -0,0 +1,59 @@ +-- CreateTable +CREATE TABLE "organizations" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + + CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "projects" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "organization_id" UUID NOT NULL, + + CONSTRAINT "projects_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "organization_id" UUID NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "events" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "properties" JSONB NOT NULL, + "project_id" UUID NOT NULL, + + CONSTRAINT "events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "profiles" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "properties" JSONB NOT NULL, + "project_id" UUID NOT NULL, + + CONSTRAINT "profiles_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "projects" ADD CONSTRAINT "projects_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "events" ADD CONSTRAINT "events_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20231010094459_add_dates/migration.sql b/apps/web/prisma/migrations/20231010094459_add_dates/migration.sql new file mode 100644 index 00000000..81e72e4b --- /dev/null +++ b/apps/web/prisma/migrations/20231010094459_add_dates/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "events" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "organizations" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "profiles" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "projects" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/web/prisma/migrations/20231010184810_fix_profile/migration.sql b/apps/web/prisma/migrations/20231010184810_fix_profile/migration.sql new file mode 100644 index 00000000..1eca309e --- /dev/null +++ b/apps/web/prisma/migrations/20231010184810_fix_profile/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `profiles` table. All the data in the column will be lost. + - Added the required column `profile_id` to the `profiles` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "profiles" DROP COLUMN "name", +ADD COLUMN "profile_id" TEXT NOT NULL; diff --git a/apps/web/prisma/migrations/20231010185023_add_profile_properties/migration.sql b/apps/web/prisma/migrations/20231010185023_add_profile_properties/migration.sql new file mode 100644 index 00000000..fdd80823 --- /dev/null +++ b/apps/web/prisma/migrations/20231010185023_add_profile_properties/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "profiles" ADD COLUMN "avatar" TEXT, +ADD COLUMN "email" TEXT, +ADD COLUMN "first_name" TEXT, +ADD COLUMN "last_name" TEXT; diff --git a/apps/web/prisma/migrations/20231010195623_add_client/migration.sql b/apps/web/prisma/migrations/20231010195623_add_client/migration.sql new file mode 100644 index 00000000..5d28ac9e --- /dev/null +++ b/apps/web/prisma/migrations/20231010195623_add_client/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "clients" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "secret" TEXT NOT NULL, + "project_id" UUID NOT NULL, + + CONSTRAINT "clients_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "clients" ADD CONSTRAINT "clients_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20231010195805_add_timestamps_on_client/migration.sql b/apps/web/prisma/migrations/20231010195805_add_timestamps_on_client/migration.sql new file mode 100644 index 00000000..1a9aea58 --- /dev/null +++ b/apps/web/prisma/migrations/20231010195805_add_timestamps_on_client/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "clients" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/web/prisma/migrations/20231010202343_add_profile_id_on_event/migration.sql b/apps/web/prisma/migrations/20231010202343_add_profile_id_on_event/migration.sql new file mode 100644 index 00000000..c8709ccc --- /dev/null +++ b/apps/web/prisma/migrations/20231010202343_add_profile_id_on_event/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `profile_id` to the `events` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "events" ADD COLUMN "profile_id" UUID NOT NULL; + +-- AddForeignKey +ALTER TABLE "events" ADD CONSTRAINT "events_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20231010202552_profile_nullable_on_events/migration.sql b/apps/web/prisma/migrations/20231010202552_profile_nullable_on_events/migration.sql new file mode 100644 index 00000000..511459c8 --- /dev/null +++ b/apps/web/prisma/migrations/20231010202552_profile_nullable_on_events/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "events" DROP CONSTRAINT "events_profile_id_fkey"; + +-- AlterTable +ALTER TABLE "events" ALTER COLUMN "profile_id" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "events" ADD CONSTRAINT "events_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20231011063223_rename_profile_id_to_external/migration.sql b/apps/web/prisma/migrations/20231011063223_rename_profile_id_to_external/migration.sql new file mode 100644 index 00000000..3da7a1d7 --- /dev/null +++ b/apps/web/prisma/migrations/20231011063223_rename_profile_id_to_external/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `profile_id` on the `profiles` table. All the data in the column will be lost. + - Added the required column `external_id` to the `profiles` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "profiles" DROP COLUMN "profile_id", +ADD COLUMN "external_id" TEXT NOT NULL; diff --git a/apps/web/prisma/migrations/20231011064100_add_unique_external_id_and_project_id/migration.sql b/apps/web/prisma/migrations/20231011064100_add_unique_external_id_and_project_id/migration.sql new file mode 100644 index 00000000..eafa2140 --- /dev/null +++ b/apps/web/prisma/migrations/20231011064100_add_unique_external_id_and_project_id/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[project_id,external_id]` on the table `profiles` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "profiles_project_id_external_id_key" ON "profiles"("project_id", "external_id"); diff --git a/apps/web/prisma/migrations/20231012082544_external_id_optional/migration.sql b/apps/web/prisma/migrations/20231012082544_external_id_optional/migration.sql new file mode 100644 index 00000000..98890914 --- /dev/null +++ b/apps/web/prisma/migrations/20231012082544_external_id_optional/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "profiles_project_id_external_id_key"; + +-- AlterTable +ALTER TABLE "profiles" ALTER COLUMN "external_id" DROP NOT NULL; diff --git a/apps/web/prisma/migrations/migration_lock.toml b/apps/web/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/apps/web/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma new file mode 100644 index 00000000..d40ef1e7 --- /dev/null +++ b/apps/web/prisma/schema.prisma @@ -0,0 +1,99 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Organization { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + projects Project[] + users User[] + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("organizations") +} + +model Project { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + organization_id String @db.Uuid + organization Organization @relation(fields: [organization_id], references: [id]) + events Event[] + profiles Profile[] + clients Client[] + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("projects") +} + +model User { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + email String + password String + organization_id String @db.Uuid + organization Organization @relation(fields: [organization_id], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("users") +} + +model Event { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + properties Json + project_id String @db.Uuid + project Project @relation(fields: [project_id], references: [id]) + + profile_id String? @db.Uuid + profile Profile? @relation(fields: [profile_id], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("events") +} + +model Profile { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + external_id String? + first_name String? + last_name String? + email String? + avatar String? + properties Json + project_id String @db.Uuid + project Project @relation(fields: [project_id], references: [id]) + events Event[] + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("profiles") +} + +model Client { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + secret String + project_id String @db.Uuid + project Project @relation(fields: [project_id], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("clients") +} diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 00000000..b5336a48 Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/src/env.mjs b/apps/web/src/env.mjs new file mode 100644 index 00000000..a95f519c --- /dev/null +++ b/apps/web/src/env.mjs @@ -0,0 +1,62 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + /** + * Specify your server-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. + */ + server: { + DATABASE_URL: z + .string() + .url() + .refine( + (str) => !str.includes("YOUR_MYSQL_URL_HERE"), + "You forgot to change the default URL" + ), + NODE_ENV: z + .enum(["development", "test", "production"]) + .default("development"), + NEXTAUTH_SECRET: + process.env.NODE_ENV === "production" + ? z.string() + : z.string().optional(), + NEXTAUTH_URL: z.preprocess( + // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL + // Since NextAuth.js automatically uses the VERCEL_URL if present. + (str) => process.env.VERCEL_URL ?? str, + // VERCEL_URL doesn't include `https` so it cant be validated as a URL + process.env.VERCEL ? z.string() : z.string().url() + ), + }, + + /** + * Specify your client-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. To expose them to the client, prefix them with + * `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string(), + }, + + /** + * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. + * middlewares) or client-side so we need to destruct manually. + */ + runtimeEnv: { + DATABASE_URL: process.env.DATABASE_URL, + NODE_ENV: process.env.NODE_ENV, + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, + NEXTAUTH_URL: process.env.NEXTAUTH_URL, + }, + /** + * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially + * useful for Docker builds. + */ + skipValidation: !!process.env.SKIP_ENV_VALIDATION, + /** + * Makes it so that empty strings are treated as undefined. + * `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error. + */ + emptyStringAsUndefined: true, +}); diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx new file mode 100644 index 00000000..2843ab6e --- /dev/null +++ b/apps/web/src/pages/_app.tsx @@ -0,0 +1,20 @@ +import { type Session } from "next-auth"; +import { SessionProvider } from "next-auth/react"; +import { type AppType } from "next/app"; + +import { api } from "@/utils/api"; + +import "@/styles/globals.css"; + +const MyApp: AppType<{ session: Session | null }> = ({ + Component, + pageProps: { session, ...pageProps }, +}) => { + return ( + + + + ); +}; + +export default api.withTRPC(MyApp); diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts new file mode 100644 index 00000000..05aa2a35 --- /dev/null +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -0,0 +1,5 @@ +import NextAuth from "next-auth"; + +import { authOptions } from "@/server/auth"; + +export default NextAuth(authOptions); diff --git a/apps/web/src/pages/api/sdk/events.ts b/apps/web/src/pages/api/sdk/events.ts new file mode 100644 index 00000000..b813ca1d --- /dev/null +++ b/apps/web/src/pages/api/sdk/events.ts @@ -0,0 +1,37 @@ +import { validateSdkRequest } from '@/server/auth' +import { db } from '@/server/db' +import { createError, handleError } from '@/server/exceptions' +import { EventPayload } from '@mixan/types' +import type { NextApiRequest, NextApiResponse } from 'next' + +interface Request extends NextApiRequest { + body: Array +} + +export default async function handler( + req: Request, + res: NextApiResponse +) { + if(req.method !== 'POST') { + return handleError(res, createError(405, 'Method not allowed')) + } + + try { + // Check client id & secret + const projectId = await validateSdkRequest(req) + + await db.event.createMany({ + data: req.body.map((event) => ({ + name: event.name, + properties: event.properties, + createdAt: event.time, + project_id: projectId, + profile_id: event.profileId, + })) + }) + + res.status(200).end() + } catch (error) { + handleError(res, error) + } +} \ No newline at end of file diff --git a/apps/web/src/pages/api/sdk/profiles/[profileId]/decrement.ts b/apps/web/src/pages/api/sdk/profiles/[profileId]/decrement.ts new file mode 100644 index 00000000..ffea7826 --- /dev/null +++ b/apps/web/src/pages/api/sdk/profiles/[profileId]/decrement.ts @@ -0,0 +1,33 @@ +import { validateSdkRequest } from "@/server/auth"; +import { db } from "@/server/db"; +import { createError, handleError } from "@/server/exceptions"; +import { tickProfileProperty } from "@/services/profile.service"; +import { ProfileIncrementPayload, ProfilePayload } from "@mixan/types"; +import type { NextApiRequest, NextApiResponse } from "next"; + +interface Request extends NextApiRequest { + body: ProfileIncrementPayload; +} + +export default async function handler(req: Request, res: NextApiResponse) { + if (req.method !== "PUT") { + return handleError(res, createError(405, "Method not allowed")); + } + + try { + // Check client id & secret + await validateSdkRequest(req) + + const profileId = req.query.profileId as string; + + await tickProfileProperty({ + name: req.body.name, + tick: -Math.abs(req.body.value), + profileId, + }); + + res.status(200).end(); + } catch (error) { + handleError(res, error); + } +} diff --git a/apps/web/src/pages/api/sdk/profiles/[profileId]/increment.ts b/apps/web/src/pages/api/sdk/profiles/[profileId]/increment.ts new file mode 100644 index 00000000..a234dca3 --- /dev/null +++ b/apps/web/src/pages/api/sdk/profiles/[profileId]/increment.ts @@ -0,0 +1,33 @@ +import { validateSdkRequest } from "@/server/auth"; +import { db } from "@/server/db"; +import { createError, handleError } from "@/server/exceptions"; +import { tickProfileProperty } from "@/services/profile.service"; +import { ProfileIncrementPayload, ProfilePayload } from "@mixan/types"; +import type { NextApiRequest, NextApiResponse } from "next"; + +interface Request extends NextApiRequest { + body: ProfileIncrementPayload; +} + +export default async function handler(req: Request, res: NextApiResponse) { + if (req.method !== "PUT") { + return handleError(res, createError(405, "Method not allowed")); + } + + try { + // Check client id & secret + await validateSdkRequest(req) + + const profileId = req.query.profileId as string; + + await tickProfileProperty({ + name: req.body.name, + tick: req.body.value, + profileId, + }); + + res.status(200).end(); + } catch (error) { + handleError(res, error); + } +} diff --git a/apps/web/src/pages/api/sdk/profiles/[profileId]/index.ts b/apps/web/src/pages/api/sdk/profiles/[profileId]/index.ts new file mode 100644 index 00000000..f243d021 --- /dev/null +++ b/apps/web/src/pages/api/sdk/profiles/[profileId]/index.ts @@ -0,0 +1,48 @@ +import { validateSdkRequest } from "@/server/auth"; +import { db } from "@/server/db"; +import { createError, handleError } from "@/server/exceptions"; +import { getProfile } from "@/services/profile.service"; +import { ProfilePayload } from "@mixan/types"; +import type { NextApiRequest, NextApiResponse } from "next"; + +interface Request extends NextApiRequest { + body: ProfilePayload; +} + +export default async function handler(req: Request, res: NextApiResponse) { + if (req.method !== "PUT" && req.method !== "POST") { + return handleError(res, createError(405, "Method not allowed")); + } + + try { + // Check client id & secret + await validateSdkRequest(req) + + const profileId = req.query.profileId as string; + const profile = await getProfile(profileId) + + const { body } = req; + await db.profile.update({ + where: { + id: profileId, + }, + data: { + external_id: body.id, + email: body.email, + first_name: body.first_name, + last_name: body.last_name, + avatar: body.avatar, + properties: { + ...(typeof profile.properties === "object" + ? profile.properties || {} + : {}), + ...(body.properties || {}), + }, + }, + }); + + res.status(200).end(); + } catch (error) { + handleError(res, error); + } +} diff --git a/apps/web/src/pages/api/sdk/profiles/index.ts b/apps/web/src/pages/api/sdk/profiles/index.ts new file mode 100644 index 00000000..f70c8ee6 --- /dev/null +++ b/apps/web/src/pages/api/sdk/profiles/index.ts @@ -0,0 +1,46 @@ +import { validateSdkRequest } from '@/server/auth' +import { db } from '@/server/db' +import { createError, handleError } from '@/server/exceptions' +import type { NextApiRequest, NextApiResponse } from 'next' +import randomAnimalName from 'random-animal-name' + +interface Request extends NextApiRequest { + body: { + id: string + properties?: Record + } +} + +export default async function handler( + req: Request, + res: NextApiResponse +) { + if(req.method !== 'POST') { + return handleError(res, createError(405, 'Method not allowed')) + } + + try { + // Check client id & secret + const projectId = await validateSdkRequest(req) + + const { id, properties } = req.body + await db.profile.create({ + data: { + id, + external_id: null, + email: null, + first_name: randomAnimalName(), + last_name: null, + avatar: null, + properties: { + ...(properties || {}), + }, + project_id: projectId, + }, + }) + + res.status(200).end() + } catch (error) { + handleError(res, error) + } +} \ No newline at end of file diff --git a/apps/web/src/pages/api/setup.ts b/apps/web/src/pages/api/setup.ts new file mode 100644 index 00000000..d9624933 --- /dev/null +++ b/apps/web/src/pages/api/setup.ts @@ -0,0 +1,47 @@ +import { db } from "@/server/db"; +import { handleError } from "@/server/exceptions"; +import { hashPassword } from "@/services/hash.service"; +import { randomUUID } from "crypto"; +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function (req: NextApiRequest, res: NextApiResponse) { + try { + const counts = await db.$transaction([ + db.organization.count(), + db.project.count(), + db.client.count(), + ]); + + if (counts.some((count) => count > 0)) { + return res.json("Setup already done"); + } + + const organization = await db.organization.create({ + data: { + name: "Acme Inc.", + }, + }); + + const project = await db.project.create({ + data: { + name: "Acme Website", + organization_id: organization.id, + }, + }); + const secret = randomUUID(); + const client = await db.client.create({ + data: { + name: "Acme Website Client", + project_id: project.id, + secret: await hashPassword(secret), + }, + }); + + res.json({ + clientId: client.id, + clientSecret: secret, + }); + } catch (error) { + handleError(res, error); + } +} diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts new file mode 100644 index 00000000..4e981b57 --- /dev/null +++ b/apps/web/src/pages/api/trpc/[trpc].ts @@ -0,0 +1,19 @@ +import { createNextApiHandler } from "@trpc/server/adapters/next"; + +import { env } from "@/env.mjs"; +import { appRouter } from "@/server/api/root"; +import { createTRPCContext } from "@/server/api/trpc"; + +// export API handler +export default createNextApiHandler({ + router: appRouter, + createContext: createTRPCContext, + onError: + env.NODE_ENV === "development" + ? ({ path, error }) => { + console.error( + `❌ tRPC failed on ${path ?? ""}: ${error.message}` + ); + } + : undefined, +}); diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx new file mode 100644 index 00000000..365788da --- /dev/null +++ b/apps/web/src/pages/index.tsx @@ -0,0 +1,80 @@ +import { signIn, signOut, useSession } from "next-auth/react"; +import Head from "next/head"; +import Link from "next/link"; + +import { api } from "@/utils/api"; + +export default function Home() { + const hello = api.example.hello.useQuery({ text: "from tRPC" }); + + return ( + <> + + Create T3 App + + + +
+
+

+ Create T3 App +

+
+ +

First Steps →

+
+ Just the basics - Everything you need to know to set up your + database and authentication. +
+ + +

Documentation →

+
+ Learn more about Create T3 App, the libraries it uses, and how + to deploy it. +
+ +
+
+

+ {hello.data ? hello.data.greeting : "Loading tRPC query..."} +

+ +
+
+
+ + ); +} + +function AuthShowcase() { + const { data: sessionData } = useSession(); + + const { data: secretMessage } = api.example.getSecretMessage.useQuery( + undefined, // no input + { enabled: sessionData?.user !== undefined } + ); + + return ( +
+

+ {sessionData && Logged in as {sessionData.user?.name}} + {secretMessage && - {secretMessage}} +

+ +
+ ); +} diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts new file mode 100644 index 00000000..0a794406 --- /dev/null +++ b/apps/web/src/server/api/root.ts @@ -0,0 +1,14 @@ +import { exampleRouter } from "@/server/api/routers/example"; +import { createTRPCRouter } from "@/server/api/trpc"; + +/** + * This is the primary router for your server. + * + * All routers added in /api/routers should be manually added here. + */ +export const appRouter = createTRPCRouter({ + example: exampleRouter, +}); + +// export type definition of API +export type AppRouter = typeof appRouter; diff --git a/apps/web/src/server/api/routers/example.ts b/apps/web/src/server/api/routers/example.ts new file mode 100644 index 00000000..8d0d4637 --- /dev/null +++ b/apps/web/src/server/api/routers/example.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +import { + createTRPCRouter, + protectedProcedure, + publicProcedure, +} from "@/server/api/trpc"; + +export const exampleRouter = createTRPCRouter({ + hello: publicProcedure + .input(z.object({ text: z.string() })) + .query(({ input }) => { + return { + greeting: `Hello ${input.text}`, + }; + }), + + getAll: publicProcedure.query(({ ctx }) => { + return ctx.db.example.findMany(); + }), + + getSecretMessage: protectedProcedure.query(() => { + return "you can now see this secret message!"; + }), +}); diff --git a/apps/web/src/server/api/trpc.ts b/apps/web/src/server/api/trpc.ts new file mode 100644 index 00000000..b7651424 --- /dev/null +++ b/apps/web/src/server/api/trpc.ts @@ -0,0 +1,131 @@ +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ + +import { initTRPC, TRPCError } from "@trpc/server"; +import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; +import { type Session } from "next-auth"; +import superjson from "superjson"; +import { ZodError } from "zod"; + +import { getServerAuthSession } from "@/server/auth"; +import { db } from "@/server/db"; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + */ + +interface CreateContextOptions { + session: Session | null; +} + +/** + * This helper generates the "internals" for a tRPC context. If you need to use it, you can export + * it from here. + * + * Examples of things you may need it for: + * - testing, so we don't have to mock Next.js' req/res + * - tRPC's `createSSGHelpers`, where we don't have req/res + * + * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts + */ +const createInnerTRPCContext = (opts: CreateContextOptions) => { + return { + session: opts.session, + db, + }; +}; + +/** + * This is the actual context you will use in your router. It will be used to process every request + * that goes through your tRPC endpoint. + * + * @see https://trpc.io/docs/context + */ +export const createTRPCContext = async (opts: CreateNextContextOptions) => { + const { req, res } = opts; + + // Get the session from the server using the getServerSession wrapper function + const session = await getServerAuthSession({ req, res }); + + return createInnerTRPCContext({ + session, + }); +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ + +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +/** + * Public (unauthenticated) procedure + * + * This is the base piece you use to build new queries and mutations on your tRPC API. It does not + * guarantee that a user querying is authorized, but you can still access user session data if they + * are logged in. + */ +export const publicProcedure = t.procedure; + +/** Reusable middleware that enforces users are logged in before running the procedure. */ +const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers the `session` as non-nullable + session: { ...ctx.session, user: ctx.session.user }, + }, + }); +}); + +/** + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures + */ +export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts new file mode 100644 index 00000000..73eef929 --- /dev/null +++ b/apps/web/src/server/auth.ts @@ -0,0 +1,123 @@ +import { PrismaAdapter } from "@next-auth/prisma-adapter"; +import { NextApiRequest, type GetServerSidePropsContext } from "next"; +import { + getServerSession, + type DefaultSession, + type NextAuthOptions, +} from "next-auth"; + +import { db } from "@/server/db"; +import Credentials from "next-auth/providers/credentials"; +import { createError } from "./exceptions"; +import { verifyPassword } from "@/services/hash.service"; + +/** + * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` + * object and keep type safety. + * + * @see https://next-auth.js.org/getting-started/typescript#module-augmentation + */ +declare module "next-auth" { + interface Session extends DefaultSession { + user: DefaultSession["user"] & { + id: string; + // ...other properties + // role: UserRole; + }; + } + + // interface User { + // // ...other properties + // // role: UserRole; + // } +} + +/** + * Options for NextAuth.js used to configure adapters, providers, callbacks, etc. + * + * @see https://next-auth.js.org/configuration/options + */ +export const authOptions: NextAuthOptions = { + callbacks: { + session: ({ session, user, token }) => ({ + ...session, + user: { + ...session.user, + id: token.sub, + }, + }), + }, + // adapter: PrismaAdapter(db), + providers: [ + Credentials({ + name: "Credentials", + credentials: { + email: { label: "Email", type: "text", placeholder: "jsmith" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const user = await db.user.findFirst({ + where: { email: credentials?.email }, + }); + + if (user) { + return user; + } else { + return null; + } + }, + }), + /** + * ...add more providers here. + * + * Most other providers require a bit more work than the Discord provider. For example, the + * GitHub provider requires you to add the `refresh_token_expires_in` field to the Account + * model. Refer to the NextAuth.js docs for the provider you want to use. Example: + * + * @see https://next-auth.js.org/providers/github + */ + ], +}; + +/** + * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file. + * + * @see https://next-auth.js.org/configuration/nextjs + */ +export const getServerAuthSession = (ctx: { + req: GetServerSidePropsContext["req"]; + res: GetServerSidePropsContext["res"]; +}) => { + return getServerSession(ctx.req, ctx.res, authOptions); +}; + +export async function validateSdkRequest(req: NextApiRequest): Promise { + const clientId = req?.headers["mixan-client-id"] as string | undefined + const clientSecret = req.headers["mixan-client-secret"] as string | undefined + + if (!clientId) { + throw createError(401, "Misisng client id"); + } + + if (!clientSecret) { + throw createError(401, "Misisng client secret"); + } + + const client = await db.client.findUnique({ + where: { + id: clientId, + }, + }); + + if (!client) { + throw createError(401, "Invalid client id"); + } + + + + if (!(await verifyPassword(clientSecret, client.secret))) { + throw createError(401, "Invalid client secret"); + } + + return client.project_id +} diff --git a/apps/web/src/server/db.ts b/apps/web/src/server/db.ts new file mode 100644 index 00000000..47fa369c --- /dev/null +++ b/apps/web/src/server/db.ts @@ -0,0 +1,16 @@ +import { PrismaClient } from "@prisma/client"; + +import { env } from "@/env.mjs"; + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +export const db = + globalForPrisma.prisma ?? + new PrismaClient({ + log: + env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], + }); + +if (env.NODE_ENV !== "production") globalForPrisma.prisma = db; diff --git a/apps/web/src/server/exceptions.ts b/apps/web/src/server/exceptions.ts new file mode 100644 index 00000000..62e39ae0 --- /dev/null +++ b/apps/web/src/server/exceptions.ts @@ -0,0 +1,55 @@ +import { + MixanIssue, + MixanErrorResponse +} from '@mixan/types' +import { NextApiResponse } from 'next' + +export class HttpError extends Error { + public status: number + public message: string + public issues: MixanIssue[] + + constructor(status: number, message: string | Error, issues?: MixanIssue[]) { + super(message instanceof Error ? message.message : message) + this.status = status + this.message = message instanceof Error ? message.message : message + this.issues = issues || [] + } + + toJson(): MixanErrorResponse { + return { + code: this.status, + status: 'error', + message: this.message, + issues: this.issues.length ? this.issues : undefined, + stack: process.env.NODE_ENV !== 'production' ? this.stack : undefined, + } + } +} + +export function createIssues(arr: Array) { + throw new HttpError(400, 'Issues', arr) +} + +export function createError(status = 500, error: unknown | Error | string) { + if(error instanceof Error || typeof error === 'string') { + return new HttpError(status, error) + } + + return new HttpError(500, 'Unexpected error occured') +} + +export function handleError(res: NextApiResponse, error: Error | HttpError | unknown) { + if(error instanceof HttpError) { + return res.status(error.status).json(error.toJson()) + } + + if(error instanceof Error) { + const httpError = createError(500, error) + res.status(httpError.status).json(httpError.toJson()) + } + + + const httpError = createError(500, error) + res.status(httpError.status).json(httpError.toJson()) +} \ No newline at end of file diff --git a/apps/web/src/services/hash.service.ts b/apps/web/src/services/hash.service.ts new file mode 100644 index 00000000..fe402eb8 --- /dev/null +++ b/apps/web/src/services/hash.service.ts @@ -0,0 +1,40 @@ +import { scrypt, randomBytes, timingSafeEqual } from "crypto"; + +const keyLength = 32; +/** + * Has a password or a secret with a password hashing algorithm (scrypt) + * @param {string} password + * @returns {string} The salt+hash + */ +export async function hashPassword (password: string): Promise { + return new Promise((resolve, reject) => { + // generate random 16 bytes long salt - recommended by NodeJS Docs + const salt = randomBytes(16).toString("hex"); + scrypt(password, salt, keyLength, (err, derivedKey) => { + if (err) reject(err); + // derivedKey is of type Buffer + resolve(`${salt}.${derivedKey.toString("hex")}`); + }); + }); +}; + +/** + * Compare a plain text password with a salt+hash password + * @param {string} password The plain text password + * @param {string} hash The hash+salt to check against + * @returns {boolean} + */ +export async function verifyPassword (password: string, hash: string): Promise { + return new Promise((resolve, reject) => { + const [salt, hashKey] = hash.split("."); + // we need to pass buffer values to timingSafeEqual + const hashKeyBuff = Buffer.from(hashKey!, "hex"); + scrypt(password, salt!, keyLength, (err, derivedKey) => { + if (err) { + reject(err); + } + // compare the new supplied password with the hashed password using timeSafeEqual + resolve(timingSafeEqual(hashKeyBuff, derivedKey)); + }); + }); +}; diff --git a/apps/web/src/services/profile.service.ts b/apps/web/src/services/profile.service.ts new file mode 100644 index 00000000..637fc97f --- /dev/null +++ b/apps/web/src/services/profile.service.ts @@ -0,0 +1,51 @@ +import { db } from "@/server/db" +import { HttpError } from "@/server/exceptions" + +export function getProfile(id: string) { + return db.profile.findUniqueOrThrow({ + where: { + id, + }, + }) +} + +export async function tickProfileProperty({ + profileId, + tick, + name, +}: { + profileId: string + tick: number + name: string +}) { + const profile = await getProfile(profileId) + + if (!profile) { + throw new HttpError(404, `Profile not found ${profileId}`) + } + + const properties = ( + typeof profile.properties === 'object' ? profile.properties || {} : {} + ) as Record + const value = name in properties ? properties[name] : 0 + + if (typeof value !== 'number') { + throw new HttpError(400, `Property "${name}" on user is of type ${typeof value}`) + } + + if (typeof tick !== 'number') { + throw new HttpError(400, `Value is not a number ${tick} (${typeof tick})`) + } + + await db.profile.update({ + where: { + id: profileId, + }, + data: { + properties: { + ...properties, + [name]: value + tick, + }, + }, + }) +} \ No newline at end of file diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/apps/web/src/styles/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/web/src/utils/api.ts b/apps/web/src/utils/api.ts new file mode 100644 index 00000000..c4078648 --- /dev/null +++ b/apps/web/src/utils/api.ts @@ -0,0 +1,68 @@ +/** + * This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which + * contains the Next.js App-wrapper, as well as your type-safe React Query hooks. + * + * We also create a few inference helpers for input and output types. + */ +import { httpBatchLink, loggerLink } from "@trpc/client"; +import { createTRPCNext } from "@trpc/next"; +import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; +import superjson from "superjson"; + +import { type AppRouter } from "@/server/api/root"; + +const getBaseUrl = () => { + 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 + return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost +}; + +/** A set of type-safe react-query hooks for your tRPC API. */ +export const api = createTRPCNext({ + config() { + return { + /** + * Transformer used for data de-serialization from the server. + * + * @see https://trpc.io/docs/data-transformers + */ + transformer: superjson, + + /** + * Links used to determine request flow from client to server. + * + * @see https://trpc.io/docs/links + */ + links: [ + loggerLink({ + enabled: (opts) => + process.env.NODE_ENV === "development" || + (opts.direction === "down" && opts.result instanceof Error), + }), + httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + }), + ], + }; + }, + /** + * Whether tRPC should await queries when server rendering pages. + * + * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false + */ + ssr: false, +}); + +/** + * Inference helper for inputs. + * + * @example type HelloInput = RouterInputs['example']['hello'] + */ +export type RouterInputs = inferRouterInputs; + +/** + * Inference helper for outputs. + * + * @example type HelloOutput = RouterOutputs['example']['hello'] + */ +export type RouterOutputs = inferRouterOutputs; diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 00000000..d4d3fa29 --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,9 @@ +import { type Config } from "tailwindcss"; + +export default { + content: ["./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +} satisfies Config; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 00000000..a3156800 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,33 @@ +{ + "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, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + ".eslintrc.cjs", + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.cjs", + "**/*.mjs" + ], + "exclude": ["node_modules"] +} diff --git a/bun.lockb b/bun.lockb index b724108c..17680273 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts index 18f22a2e..6f74bd0e 100644 --- a/packages/sdk/index.ts +++ b/packages/sdk/index.ts @@ -54,7 +54,7 @@ class Fetcher { >() if('status' in response && response.status === 'error') { - this.logger(`Mixan request failed: ${url}`, JSON.stringify(response, null, 2)) + this.logger(`Mixan request failed: [${options.method || 'POST'}] ${url}`, JSON.stringify(response, null, 2)) return null }