diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc new file mode 100644 index 000000000..984955355 --- /dev/null +++ b/.cursor/rules/ultracite.mdc @@ -0,0 +1,333 @@ +--- +description: Ultracite Rules - AI-Ready Formatter and Linter +globs: "**/*.{ts,tsx,js,jsx}" +alwaysApply: true +--- + +# Project Context +Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. + +## Key Principles +- Zero configuration required +- Subsecond performance +- Maximum type safety +- AI-friendly code generation + +## Before Writing Code +1. Analyze existing patterns in the codebase +2. Consider edge cases and error scenarios +3. Follow the rules below strictly +4. Validate accessibility requirements + +## Rules + +### Accessibility (a11y) +- Don't use `accessKey` attribute on any HTML element. +- Don't set `aria-hidden="true"` on focusable elements. +- Don't add ARIA roles, states, and properties to elements that don't support them. +- Don't use distracting elements like `` or ``. +- Only use the `scope` prop on `` elements. +- Don't assign non-interactive ARIA roles to interactive HTML elements. +- Make sure label elements have text content and are associated with an input. +- Don't assign interactive ARIA roles to non-interactive HTML elements. +- Don't assign `tabIndex` to non-interactive HTML elements. +- Don't use positive integers for `tabIndex` property. +- Don't include "image", "picture", or "photo" in img alt prop. +- Don't use explicit role property that's the same as the implicit/default role. +- Make static elements with click handlers use a valid role attribute. +- Always include a `title` element for SVG elements. +- Give all elements requiring alt text meaningful information for screen readers. +- Make sure anchors have content that's accessible to screen readers. +- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. +- Include all required ARIA attributes for elements with ARIA roles. +- Make sure ARIA properties are valid for the element's supported roles. +- Always include a `type` attribute for button elements. +- Make elements with interactive roles and handlers focusable. +- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). +- Always include a `lang` attribute on the html element. +- Always include a `title` attribute for iframe elements. +- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. +- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. +- Include caption tracks for audio and video elements. +- Use semantic elements instead of role attributes in JSX. +- Make sure all anchors are valid and navigable. +- Ensure all ARIA properties (`aria-*`) are valid. +- Use valid, non-abstract ARIA roles for elements with ARIA roles. +- Use valid ARIA state and property values. +- Use valid values for the `autocomplete` attribute on input elements. +- Use correct ISO language/country codes for the `lang` attribute. + +### Code Complexity and Quality +- Don't use consecutive spaces in regular expression literals. +- Don't use the `arguments` object. +- Don't use primitive type aliases or misleading types. +- Don't use the comma operator. +- Don't use empty type parameters in type aliases and interfaces. +- Don't write functions that exceed a given Cognitive Complexity score. +- Don't nest describe() blocks too deeply in test files. +- Don't use unnecessary boolean casts. +- Don't use unnecessary callbacks with flatMap. +- Use for...of statements instead of Array.forEach. +- Don't create classes that only have static members (like a static namespace). +- Don't use this and super in static contexts. +- Don't use unnecessary catch clauses. +- Don't use unnecessary constructors. +- Don't use unnecessary continue statements. +- Don't export empty modules that don't change anything. +- Don't use unnecessary escape sequences in regular expression literals. +- Don't use unnecessary fragments. +- Don't use unnecessary labels. +- Don't use unnecessary nested block statements. +- Don't rename imports, exports, and destructured assignments to the same name. +- Don't use unnecessary string or template literal concatenation. +- Don't use String.raw in template literals when there are no escape sequences. +- Don't use useless case statements in switch statements. +- Don't use ternary operators when simpler alternatives exist. +- Don't use useless `this` aliasing. +- Don't use any or unknown as type constraints. +- Don't initialize variables to undefined. +- Don't use the void operators (they're not familiar). +- Use arrow functions instead of function expressions. +- Use Date.now() to get milliseconds since the Unix Epoch. +- Use .flatMap() instead of map().flat() when possible. +- Use literal property access instead of computed property access. +- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. +- Use concise optional chaining instead of chained logical expressions. +- Use regular expression literals instead of the RegExp constructor when possible. +- Don't use number literal object member names that aren't base 10 or use underscore separators. +- Remove redundant terms from logical expressions. +- Use while loops instead of for loops when you don't need initializer and update expressions. +- Don't pass children as props. +- Don't reassign const variables. +- Don't use constant expressions in conditions. +- Don't use `Math.min` and `Math.max` to clamp values when the result is constant. +- Don't return a value from a constructor. +- Don't use empty character classes in regular expression literals. +- Don't use empty destructuring patterns. +- Don't call global object properties as functions. +- Don't declare functions and vars that are accessible outside their block. +- Make sure builtins are correctly instantiated. +- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. +- Don't use variables and function parameters before they're declared. +- Don't use 8 and 9 escape sequences in string literals. +- Don't use literal numbers that lose precision. + +### React and JSX Best Practices +- Don't use the return value of React.render. +- Make sure all dependencies are correctly specified in React hooks. +- Make sure all React hooks are called from the top level of component functions. +- Don't forget key props in iterators and collection literals. +- Don't destructure props inside JSX components in Solid projects. +- Don't define React components inside other components. +- Don't use event handlers on non-interactive elements. +- Don't assign to React component props. +- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. +- Don't use dangerous JSX props. +- Don't use Array index in keys. +- Don't insert comments as text nodes. +- Don't assign JSX properties multiple times. +- Don't add extra closing tags for components without children. +- Use `<>...` instead of `...`. +- Watch out for possible "wrong" semicolons inside JSX elements. + +### Correctness and Safety +- Don't assign a value to itself. +- Don't return a value from a setter. +- Don't compare expressions that modify string case with non-compliant values. +- Don't use lexical declarations in switch clauses. +- Don't use variables that haven't been declared in the document. +- Don't write unreachable code. +- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass. +- Don't use control flow statements in finally blocks. +- Don't use optional chaining where undefined values aren't allowed. +- Don't have unused function parameters. +- Don't have unused imports. +- Don't have unused labels. +- Don't have unused private class members. +- Don't have unused variables. +- Make sure void (self-closing) elements don't have children. +- Don't return a value from a function with the return type 'void' +- Use isNaN() when checking for NaN. +- Make sure "for" loop update clauses move the counter in the right direction. +- Make sure typeof expressions are compared to valid values. +- Make sure generator functions contain yield. +- Don't use await inside loops. +- Don't use bitwise operators. +- Don't use expressions where the operation doesn't change the value. +- Make sure Promise-like statements are handled appropriately. +- Don't use __dirname and __filename in the global scope. +- Prevent import cycles. +- Don't use configured elements. +- Don't hardcode sensitive data like API keys and tokens. +- Don't let variable declarations shadow variables from outer scopes. +- Don't use the TypeScript directive @ts-ignore. +- Prevent duplicate polyfills from Polyfill.io. +- Don't use useless backreferences in regular expressions that always match empty strings. +- Don't use unnecessary escapes in string literals. +- Don't use useless undefined. +- Make sure getters and setters for the same property are next to each other in class and object definitions. +- Make sure object literals are declared consistently (defaults to explicit definitions). +- Use static Response methods instead of new Response() constructor when possible. +- Make sure switch-case statements are exhaustive. +- Make sure the `preconnect` attribute is used when using Google Fonts. +- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. +- Make sure iterable callbacks return consistent values. +- Use `with { type: "json" }` for JSON module imports. +- Use numeric separators in numeric literals. +- Use object spread instead of `Object.assign()` when constructing new objects. +- Always use the radix argument when using `parseInt()`. +- Make sure JSDoc comment lines start with a single asterisk, except for the first one. +- Include a description parameter for `Symbol()`. +- Don't use spread (`...`) syntax on accumulators. +- Don't use the `delete` operator. +- Don't access namespace imports dynamically. +- Don't use namespace imports. +- Declare regex literals at the top level. +- Don't use `target="_blank"` without `rel="noopener"`. + +### TypeScript Best Practices +- Don't use TypeScript enums. +- Don't export imported variables. +- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. +- Don't use TypeScript namespaces. +- Don't use non-null assertions with the `!` postfix operator. +- Don't use parameter properties in class constructors. +- Don't use user-defined types. +- Use `as const` instead of literal types and type annotations. +- Use either `T[]` or `Array` consistently. +- Initialize each enum member value explicitly. +- Use `export type` for types. +- Use `import type` for types. +- Make sure all enum members are literal values. +- Don't use TypeScript const enum. +- Don't declare empty interfaces. +- Don't let variables evolve into any type through reassignments. +- Don't use the any type. +- Don't misuse the non-null assertion operator (!) in TypeScript files. +- Don't use implicit any type on variable declarations. +- Don't merge interfaces and classes unsafely. +- Don't use overload signatures that aren't next to each other. +- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. + +### Style and Consistency +- Don't use global `eval()`. +- Don't use callbacks in asynchronous tests and hooks. +- Don't use negation in `if` statements that have `else` clauses. +- Don't use nested ternary expressions. +- Don't reassign function parameters. +- This rule lets you specify global variable names you don't want to use in your application. +- Don't use specified modules when loaded by import or require. +- Don't use constants whose value is the upper-case version of their name. +- Use `String.slice()` instead of `String.substr()` and `String.substring()`. +- Don't use template literals if you don't need interpolation or special-character handling. +- Don't use `else` blocks when the `if` block breaks early. +- Don't use yoda expressions. +- Don't use Array constructors. +- Use `at()` instead of integer index access. +- Follow curly brace conventions. +- Use `else if` instead of nested `if` statements in `else` clauses. +- Use single `if` statements instead of nested `if` clauses. +- Use `new` for all builtins except `String`, `Number`, and `Boolean`. +- Use consistent accessibility modifiers on class properties and methods. +- Use `const` declarations for variables that are only assigned once. +- Put default function parameters and optional function parameters last. +- Include a `default` clause in switch statements. +- Use the `**` operator instead of `Math.pow`. +- Use `for-of` loops when you need the index to extract an item from the iterated array. +- Use `node:assert/strict` over `node:assert`. +- Use the `node:` protocol for Node.js builtin modules. +- Use Number properties instead of global ones. +- Use assignment operator shorthand where possible. +- Use function types instead of object types with call signatures. +- Use template literals over string concatenation. +- Use `new` when throwing an error. +- Don't throw non-Error values. +- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. +- Use standard constants instead of approximated literals. +- Don't assign values in expressions. +- Don't use async functions as Promise executors. +- Don't reassign exceptions in catch clauses. +- Don't reassign class members. +- Don't compare against -0. +- Don't use labeled statements that aren't loops. +- Don't use void type outside of generic or return types. +- Don't use console. +- Don't use control characters and escape sequences that match control characters in regular expression literals. +- Don't use debugger. +- Don't assign directly to document.cookie. +- Use `===` and `!==`. +- Don't use duplicate case labels. +- Don't use duplicate class members. +- Don't use duplicate conditions in if-else-if chains. +- Don't use two keys with the same name inside objects. +- Don't use duplicate function parameter names. +- Don't have duplicate hooks in describe blocks. +- Don't use empty block statements and static blocks. +- Don't let switch clauses fall through. +- Don't reassign function declarations. +- Don't allow assignments to native objects and read-only global variables. +- Use Number.isFinite instead of global isFinite. +- Use Number.isNaN instead of global isNaN. +- Don't assign to imported bindings. +- Don't use irregular whitespace characters. +- Don't use labels that share a name with a variable. +- Don't use characters made with multiple code points in character class syntax. +- Make sure to use new and constructor properly. +- Don't use shorthand assign when the variable appears on both sides. +- Don't use octal escape sequences in string literals. +- Don't use Object.prototype builtins directly. +- Don't redeclare variables, functions, classes, and types in the same scope. +- Don't have redundant "use strict". +- Don't compare things where both sides are exactly the same. +- Don't let identifiers shadow restricted names. +- Don't use sparse arrays (arrays with holes). +- Don't use template literal placeholder syntax in regular strings. +- Don't use the then property. +- Don't use unsafe negation. +- Don't use var. +- Don't use with statements in non-strict contexts. +- Make sure async functions actually use await. +- Make sure default clauses in switch statements come last. +- Make sure to pass a message value when creating a built-in error. +- Make sure get methods always return a value. +- Use a recommended display strategy with Google Fonts. +- Make sure for-in loops include an if statement. +- Use Array.isArray() instead of instanceof Array. +- Make sure to use the digits argument with Number#toFixed(). +- Make sure to use the "use strict" directive in script files. + +### Next.js Specific Rules +- Don't use `` elements in Next.js projects. +- Don't use `` elements in Next.js projects. +- Don't import next/document outside of pages/_document.jsx in Next.js projects. +- Don't use the next/head module in pages/_document.js on Next.js projects. + +### Testing Best Practices +- Don't use export or module.exports in test files. +- Don't use focused tests. +- Make sure the assertion function, like expect, is placed inside an it() function call. +- Don't use disabled tests. + +## Common Tasks +- `npx ultracite init` - Initialize Ultracite in your project +- `npx ultracite format` - Format and fix code automatically +- `npx ultracite lint` - Check for issues without fixing + +## Example: Error Handling +```typescript +// ✅ Good: Comprehensive error handling +try { + const result = await fetchData(); + return { success: true, data: result }; +} catch (error) { + console.error('API call failed:', error); + return { success: false, error: error.message }; +} + +// ❌ Bad: Swallowing errors +try { + return await fetchData(); +} catch (e) { + console.log(e); +} +``` \ No newline at end of file diff --git a/.gitignore b/.gitignore index 64bacdccd..aff7a5ad5 100644 --- a/.gitignore +++ b/.gitignore @@ -196,3 +196,101 @@ android/ .tinyb # Turborepo .turbo + +# ================================================= +# FROM EXPO +# ================================================= + +# OSX +# +.DS_Store + +# XDE +.expo/ + +# VSCode +.vscode/* +!.vscode/settings.json +jsconfig.json + +# Xcode +# +build/ +!package/plugin/build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local +project.xcworkspace + +# Android/IJ +# +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ +example/.cxx/ +*.keystore +!debug.keystore +.kotlin/ + +# Bundle +# +vendor/ + +# Cocoapods +# +**/Pods +example/vendor/bundle + +# Temporary files created by Metro to check the health of the file watcher +63 .metro-health-check* + +# node.js +# +node_modules/ +npm-debug.log + +# BUCK +buck-out/ +\.buckd/ + +#Android +android/app/libs +android/keystores/debug.keystore +android/.cxx +**/android/bin +**/android/build + +**/android/.project +**/.settings + +# Expo +.expo/* + +# generated by bob +package/lib +# Gradle +android/gradle/ +android/.gradle + +# Yalc +.yalc +yalc.lock + +# Typescript +**/tsconfig.tsbuildinfo \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..a2c091a99 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "emmet.showExpandedAbbreviation": "never", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + } +} diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 000000000..c8789f748 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,61 @@ +{ + "formatter": "language_server", + "format_on_save": "on", + "languages": { + "JavaScript": { + "formatter": { + "language_server": { + "name": "biome" + } + }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + }, + "TypeScript": { + "formatter": { + "language_server": { + "name": "biome" + } + }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + }, + "JSX": { + "formatter": { + "language_server": { + "name": "biome" + } + }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + }, + "TSX": { + "formatter": { + "language_server": { + "name": "biome" + } + }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + } + }, + "lsp": { + "typescript-language-server": { + "settings": { + "typescript": { + "preferences": { + "includePackageJsonAutoImports": "on" + } + } + } + } + } +} diff --git a/apps/cli/LICENSE.md b/apps/cli/LICENSE.md new file mode 100644 index 000000000..cc433146c --- /dev/null +++ b/apps/cli/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Voidhash s.r.o. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 000000000..673e3af38 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,7 @@ +# Voidhash CLI + +The Voidhash CLI is a tool for managing your Voidhash project. + +## Installation + +Install with `bunx @voidhash/cli@latest` diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 000000000..5af9f55e1 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,34 @@ +{ + "name": "@voidhash/cli", + "version": "0.0.1-alpha.1", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/voidhashcom/voidhash", + "directory": "packages/db" + }, + "main": "./index.ts", + "exports": { + ".": "./src/index.ts", + "./schema": "./src/schema.ts" + }, + "scripts": { + "start": "tsx ./src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@effect/cli": "^0.69.2", + "@effect/platform": "^0.90.6", + "@effect/platform-bun": "^0.79.0", + "@voidhash/lib": "workspace:*" + }, + "devDependencies": { + "@voidhash/tsconfig": "workspace:*", + "tsx": "^4.19.3", + "typescript": "5.8.3", + "vitest": "^3.0.9" + }, + "peerDependencies": { + "effect": "^3.16.10" + } +} \ No newline at end of file diff --git a/apps/cli/src/commands/auth-login.ts b/apps/cli/src/commands/auth-login.ts new file mode 100644 index 000000000..e57f987ca --- /dev/null +++ b/apps/cli/src/commands/auth-login.ts @@ -0,0 +1,6 @@ +import { Command } from '@effect/cli'; +import { Effect } from 'effect'; + +export const loginCommand = Command.make('login', {}, () => + Effect.gen(function* () {}) +).pipe(Command.withDescription('Login to the Voidhash CLI.')); diff --git a/apps/cli/src/commands/auth-logout.ts b/apps/cli/src/commands/auth-logout.ts new file mode 100644 index 000000000..252eb4690 --- /dev/null +++ b/apps/cli/src/commands/auth-logout.ts @@ -0,0 +1,6 @@ +import { Command } from '@effect/cli'; +import { Effect } from 'effect'; + +export const logoutCommand = Command.make('logout', {}, () => + Effect.gen(function* () {}) +).pipe(Command.withDescription('Logout from the Voidhash CLI.')); diff --git a/apps/cli/src/commands/auth-status.ts b/apps/cli/src/commands/auth-status.ts new file mode 100644 index 000000000..adba77282 --- /dev/null +++ b/apps/cli/src/commands/auth-status.ts @@ -0,0 +1,6 @@ +import { Command } from '@effect/cli'; +import { Effect } from 'effect'; + +export const authStatusCommand = Command.make('status', {}, () => + Effect.gen(function* () {}) +).pipe(Command.withDescription('Check the status of the Voidhash CLI.')); diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts new file mode 100644 index 000000000..ead08c25f --- /dev/null +++ b/apps/cli/src/commands/auth.ts @@ -0,0 +1,14 @@ +import { Command } from '@effect/cli'; +import { Effect } from 'effect'; +import { loginCommand } from './auth-login'; +import { logoutCommand } from './auth-logout'; +import { authStatusCommand } from './auth-status'; + +export const authCommand = Command.make('auth', {}, () => + Effect.gen(function* () { + // TODO: Show sucommands documentation + }) +).pipe( + Command.withDescription('Manage the Voidhash authentication.'), + Command.withSubcommands([loginCommand, logoutCommand, authStatusCommand]) +); diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts new file mode 100644 index 000000000..5a3688217 --- /dev/null +++ b/apps/cli/src/commands/init.ts @@ -0,0 +1,6 @@ +import { Command } from '@effect/cli'; +import { Effect } from 'effect'; + +export const initCommand = Command.make('init', {}, () => + Effect.gen(function* () {}) +).pipe(Command.withDescription('Initialize a new Voidhash project.')); diff --git a/apps/cli/src/commands/schema-check.ts b/apps/cli/src/commands/schema-check.ts new file mode 100644 index 000000000..e00d384a5 --- /dev/null +++ b/apps/cli/src/commands/schema-check.ts @@ -0,0 +1,6 @@ +import { Command } from '@effect/cli'; +import { Effect } from 'effect'; + +export const schemaCheckCommand = Command.make('check', {}, () => + Effect.gen(function* () {}) +).pipe(Command.withDescription('Check the Voidhash schema.')); diff --git a/apps/cli/src/commands/schema-pull.ts b/apps/cli/src/commands/schema-pull.ts new file mode 100644 index 000000000..3b38b1f4c --- /dev/null +++ b/apps/cli/src/commands/schema-pull.ts @@ -0,0 +1,6 @@ +import { Command } from '@effect/cli'; +import { Effect } from 'effect'; + +export const schemaPullCommand = Command.make('pull', {}, () => + Effect.gen(function* () {}) +).pipe(Command.withDescription('Pull the Voidhash schema from the database.')); diff --git a/apps/cli/src/commands/schema-push.ts b/apps/cli/src/commands/schema-push.ts new file mode 100644 index 000000000..ace62621b --- /dev/null +++ b/apps/cli/src/commands/schema-push.ts @@ -0,0 +1,6 @@ +import { Command } from '@effect/cli'; +import { Effect } from 'effect'; + +export const schemaPushCommand = Command.make('push', {}, () => + Effect.gen(function* () {}) +).pipe(Command.withDescription('Push the Voidhash schema to the database.')); diff --git a/apps/cli/src/commands/schema.ts b/apps/cli/src/commands/schema.ts new file mode 100644 index 000000000..5e4956fad --- /dev/null +++ b/apps/cli/src/commands/schema.ts @@ -0,0 +1,16 @@ +import { Command } from '@effect/cli'; +import { Effect } from 'effect'; +import { schemaCheckCommand } from './schema-check'; +import { schemaPullCommand } from './schema-pull'; +import { schemaPushCommand } from './schema-push'; + +export const schemaCommand = Command.make('schema', {}, () => + Effect.gen(function* () {}) +).pipe( + Command.withDescription('Manage the Voidhash schema.'), + Command.withSubcommands([ + schemaPullCommand, + schemaPushCommand, + schemaCheckCommand + ]) +); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts new file mode 100644 index 000000000..96d688372 --- /dev/null +++ b/apps/cli/src/index.ts @@ -0,0 +1,24 @@ +import { Command } from '@effect/cli'; +import { BunContext, BunRuntime } from '@effect/platform-bun'; +import { Effect, Layer } from 'effect'; +import { authCommand } from './commands/auth'; +import { initCommand } from './commands/init'; +import { schemaCommand } from './commands/schema'; + +const MainLayer = Layer.mergeAll(BunContext.layer); + +const command = Command.make('voidhash').pipe( + Command.withDescription('Voidhash CLI application.'), + Command.withSubcommands([initCommand, authCommand, schemaCommand]) +); + +const cli = Command.run(command, { + name: 'Voidhash CLI', + version: '0.0.1-alpha.1' +}); + +Effect.suspend(() => cli(process.argv)).pipe( + Effect.provide(MainLayer), + Effect.tapErrorCause(Effect.logError), + BunRuntime.runMain +); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 000000000..ddb385d89 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@voidhash/tsconfig/internal-package.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/apps/docs/README.md b/apps/docs/README.md index b6f494e5c..00c301479 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -8,7 +8,7 @@ Run development server: ```bash npm run dev # or -pnpm dev +bun dev # or yarn dev ``` diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index 457dcf29d..e9a844cc9 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -4,7 +4,7 @@ const withMDX = createMDX(); /** @type {import('next').NextConfig} */ const config = { - reactStrictMode: true, + reactStrictMode: true }; export default withMDX(config); diff --git a/apps/docs/package.json b/apps/docs/package.json index ca848bad1..58a3736e3 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -29,4 +29,4 @@ "eslint": "^8", "eslint-config-next": "15.3.2" } -} \ No newline at end of file +} diff --git a/apps/docs/postcss.config.mjs b/apps/docs/postcss.config.mjs index a34a3d560..50a4f5b92 100644 --- a/apps/docs/postcss.config.mjs +++ b/apps/docs/postcss.config.mjs @@ -1,5 +1,5 @@ export default { plugins: { - '@tailwindcss/postcss': {}, - }, + '@tailwindcss/postcss': {} + } }; diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts index 866a32c72..cb53470fc 100644 --- a/apps/docs/source.config.ts +++ b/apps/docs/source.config.ts @@ -2,22 +2,22 @@ import { defineConfig, defineDocs, frontmatterSchema, - metaSchema, + metaSchema } from 'fumadocs-mdx/config'; // You can customise Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.vercel.app/docs/mdx/collections#define-docs export const docs = defineDocs({ docs: { - schema: frontmatterSchema, + schema: frontmatterSchema }, meta: { - schema: metaSchema, - }, + schema: metaSchema + } }); export default defineConfig({ mdxOptions: { // MDX options - }, + } }); diff --git a/apps/docs/src/app/api/search/route.ts b/apps/docs/src/app/api/search/route.ts index df889626d..6f738448e 100644 --- a/apps/docs/src/app/api/search/route.ts +++ b/apps/docs/src/app/api/search/route.ts @@ -1,4 +1,4 @@ -import { source } from '@/lib/source'; import { createFromSource } from 'fumadocs-core/search/server'; +import { source } from '@/lib/source'; export const { GET } = createFromSource(source); diff --git a/apps/docs/src/app/docs/[[...slug]]/page.tsx b/apps/docs/src/app/docs/[[...slug]]/page.tsx index 3cb6d70a0..29f5c876f 100644 --- a/apps/docs/src/app/docs/[[...slug]]/page.tsx +++ b/apps/docs/src/app/docs/[[...slug]]/page.tsx @@ -1,12 +1,12 @@ -import { source } from '@/lib/source'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; import { - DocsPage, DocsBody, DocsDescription, - DocsTitle, + DocsPage, + DocsTitle } from 'fumadocs-ui/page'; import { notFound } from 'next/navigation'; -import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { source } from '@/lib/source'; import { getMDXComponents } from '@/mdx-components'; export default async function Page(props: { @@ -14,19 +14,21 @@ export default async function Page(props: { }) { const params = await props.params; const page = source.getPage(params.slug); - if (!page) notFound(); + if (!page) { + notFound(); + } const MDXContent = page.data.body; return ( - + {page.data.title} {page.data.description} @@ -34,7 +36,7 @@ export default async function Page(props: { ); } -export async function generateStaticParams() { +export function generateStaticParams() { return source.generateParams(); } @@ -43,10 +45,12 @@ export async function generateMetadata(props: { }) { const params = await props.params; const page = source.getPage(params.slug); - if (!page) notFound(); + if (!page) { + notFound(); + } return { title: page.data.title, - description: page.data.description, + description: page.data.description }; } diff --git a/apps/docs/src/app/global.css b/apps/docs/src/app/global.css index f2b48e6a5..f9a21d72a 100644 --- a/apps/docs/src/app/global.css +++ b/apps/docs/src/app/global.css @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/nursery/noUnknownAtRule: tailwind */ @import "tailwindcss"; @import "fumadocs-ui/css/shadcn.css"; @import "fumadocs-ui/css/preset.css"; @@ -7,165 +8,165 @@ @custom-variant dark (&:is(.dark *)); ::selection { - background: #0075f2; - color: #fff; + background: #0075f2; + color: #fff; } :root { - --background: oklch(0.985 0 0); - --foreground: oklch(0.145 0 0); - --surface: oklch(1 0 0); - --surface-muted: oklch(0.97 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - /* --primary: oklch(0.205 0 0); + --background: oklch(0.985 0 0); + --foreground: oklch(0.145 0 0); + --surface: oklch(1 0 0); + --surface-muted: oklch(0.97 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + /* --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); */ - --primary: oklch(0.55 0.25 261.78); - --primary-foreground: oklch(1.0 0 0); - --secondary: oklch(0.99 0 0); - --secondary-foreground: oklch(0.2 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.577 0.245 27.325); - --success: oklch(0.845 0.222 133); - --success-foreground: oklch(0.985 0 0); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --primary: oklch(0.55 0.25 261.78); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.99 0 0); + --secondary-foreground: oklch(0.2 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --success: oklch(0.845 0.222 133); + --success-foreground: oklch(0.985 0 0); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --surface: oklch(0.24 0 0); - --surface-muted: oklch(0.18 0 0); - --card: oklch(0.17 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.145 0 0); - --popover-foreground: oklch(0.985 0 0); - /* --primary: oklch(0.985 0 0); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --surface: oklch(0.24 0 0); + --surface-muted: oklch(0.18 0 0); + --card: oklch(0.17 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + /* --primary: oklch(0.985 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.205 0 0); --secondary-foreground: oklch(0.985 0 0); */ - --primary: oklch(0.55 0.25 261.78); - --primary-foreground: oklch(1.0 0 0); - --secondary: oklch(0.26 0 0); - --secondary-foreground: oklch(0.99 0 0); - --muted: oklch(0.205 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); - --success: oklch(0.845 0.222 133); - --success-foreground: oklch(0.985 0 0); - --border: oklch(0.269 0 0); - --input: oklch(0.269 0 0); - --ring: oklch(0.439 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.145 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.205 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(0.205 0 0); - --sidebar-ring: oklch(0.439 0 0); + --primary: oklch(0.55 0.25 261.78); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.26 0 0); + --secondary-foreground: oklch(0.99 0 0); + --muted: oklch(0.205 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --success: oklch(0.845 0.222 133); + --success-foreground: oklch(0.985 0 0); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.145 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.205 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.205 0 0); + --sidebar-ring: oklch(0.439 0 0); } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-surface: var(--surface); - --color-surface-muted: var(--surface-muted); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-success: var(--success); - --color-success-foreground: var(--success-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - --animate-accordion-down: accordion-down 0.2s ease-out; - --animate-accordion-up: accordion-up 0.2s ease-out; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-surface: var(--surface); + --color-surface-muted: var(--surface-muted); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; - @keyframes accordion-down { - from { - height: 0; - } - to { - height: var(--radix-accordion-content-height); - } - } + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - to { - height: 0; - } - } + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } } @layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } diff --git a/apps/docs/src/app/layout.config.tsx b/apps/docs/src/app/layout.config.tsx index f2043d122..f9c65eb21 100644 --- a/apps/docs/src/app/layout.config.tsx +++ b/apps/docs/src/app/layout.config.tsx @@ -12,17 +12,18 @@ export const baseOptions: BaseLayoutProps = { title: ( <> - + Logo + My App - ), + ) }, // see https://fumadocs.dev/docs/ui/navigation/links - links: [], + links: [] }; diff --git a/apps/docs/src/app/layout.tsx b/apps/docs/src/app/layout.tsx index 9b67cddf8..1510cb86c 100644 --- a/apps/docs/src/app/layout.tsx +++ b/apps/docs/src/app/layout.tsx @@ -4,13 +4,13 @@ import { Inter } from 'next/font/google'; import type { ReactNode } from 'react'; const inter = Inter({ - subsets: ['latin'], + subsets: ['latin'] }); export default function Layout({ children }: { children: ReactNode }) { return ( - - + + {children} diff --git a/apps/docs/src/lib/source.ts b/apps/docs/src/lib/source.ts index dedc4be83..8e46a5b7c 100644 --- a/apps/docs/src/lib/source.ts +++ b/apps/docs/src/lib/source.ts @@ -1,9 +1,9 @@ -import { docs } from '@/.source'; import { loader } from 'fumadocs-core/source'; +import { docs } from '@/.source'; // See https://fumadocs.vercel.app/docs/headless/source-api for more info export const source = loader({ // it assigns a URL to your pages baseUrl: '/docs', - source: docs.toFumadocsSource(), + source: docs.toFumadocsSource() }); diff --git a/apps/docs/src/mdx-components.tsx b/apps/docs/src/mdx-components.tsx index d3fbb13fb..7b2760c85 100644 --- a/apps/docs/src/mdx-components.tsx +++ b/apps/docs/src/mdx-components.tsx @@ -5,6 +5,6 @@ import type { MDXComponents } from 'mdx/types'; export function getMDXComponents(components?: MDXComponents): MDXComponents { return { ...defaultMdxComponents, - ...components, + ...components }; } diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 504b29117..8730cf889 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -2,11 +2,7 @@ "compilerOptions": { "baseUrl": ".", "target": "ESNext", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -20,12 +16,8 @@ "jsx": "preserve", "incremental": true, "paths": { - "@/.source": [ - "./.source/index.ts" - ], - "@/*": [ - "./src/*" - ] + "@/.source": ["./.source/index.ts"], + "@/*": ["./src/*"] }, "plugins": [ { @@ -33,13 +25,6 @@ } ] }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/web/app/api/(internal)/auth/[...all]/route.ts b/apps/web/app/api/(internal)/auth/[...all]/route.ts index 65d8ac8b2..5e68ae943 100644 --- a/apps/web/app/api/(internal)/auth/[...all]/route.ts +++ b/apps/web/app/api/(internal)/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@voidhash/auth"; -import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from '@voidhash/auth'; +import { toNextJsHandler } from 'better-auth/next-js'; export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/apps/web/app/api/(internal)/trpc/[trpc]/route.ts b/apps/web/app/api/(internal)/trpc/[trpc]/route.ts index 5c2a28da6..cfc3c6783 100644 --- a/apps/web/app/api/(internal)/trpc/[trpc]/route.ts +++ b/apps/web/app/api/(internal)/trpc/[trpc]/route.ts @@ -1,47 +1,48 @@ -import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; -import { auth } from "@voidhash/auth"; +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import { auth } from '@voidhash/auth'; -import { appRouter, createTRPCContext } from "@/lib/trpc"; +import { appRouter, createTRPCContext } from '@/lib/trpc'; /** * Configure basic CORS headers * You should extend this to match your needs */ const setCorsHeaders = (res: Response) => { - res.headers.set("Access-Control-Allow-Origin", "*"); - res.headers.set("Access-Control-Request-Method", "*"); - res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST"); - res.headers.set("Access-Control-Allow-Headers", "*"); + res.headers.set('Access-Control-Allow-Origin', '*'); + res.headers.set('Access-Control-Request-Method', '*'); + res.headers.set('Access-Control-Allow-Methods', 'OPTIONS, GET, POST'); + res.headers.set('Access-Control-Allow-Headers', '*'); }; export const OPTIONS = () => { - const response = new Response(null, { - status: 204, - }); - setCorsHeaders(response); - return response; + const response = new Response(null, { + status: 204 + }); + setCorsHeaders(response); + return response; }; const handler = async (req: Request) => { - const session = await auth.api.getSession({ - headers: req.headers, - }); - const response = await fetchRequestHandler({ - endpoint: "/api/trpc", - router: appRouter, - req, - createContext: () => - createTRPCContext({ - session: session, - headers: req.headers, - }), - onError({ error, path }) { - console.error(`>>> tRPC Error on '${path}'`, error); - }, - }); + const session = await auth.api.getSession({ + headers: req.headers + }); + const response = await fetchRequestHandler({ + endpoint: '/api/trpc', + router: appRouter, + req, + createContext: () => + createTRPCContext({ + session, + headers: req.headers + }), + onError({ error, path }) { + // biome-ignore lint/suspicious/noConsole: We want to log errors to the console + console.error(`>>> tRPC Error on '${path}'`, error); + } + }); - setCorsHeaders(response); - return response; + setCorsHeaders(response); + return response; }; export { handler as GET, handler as POST }; diff --git a/apps/web/app/api/(public)/[[...route]]/route.ts b/apps/web/app/api/(public)/[[...route]]/route.ts index 46ad30c93..6e36badf1 100644 --- a/apps/web/app/api/(public)/[[...route]]/route.ts +++ b/apps/web/app/api/(public)/[[...route]]/route.ts @@ -1,7 +1,7 @@ -import { app } from "@/lib/api/api"; -import { handle } from "hono/vercel"; +import { handle } from 'hono/vercel'; +import { app } from '@/lib/api/api'; -export const runtime = "nodejs"; +export const runtime = 'nodejs'; export const GET = handle(app); export const POST = handle(app); diff --git a/apps/web/app/app.voidhash.com/(auth)/login/loading.tsx b/apps/web/app/app.voidhash.com/(auth)/login/loading.tsx index 5b33c5e53..d492b8ddf 100644 --- a/apps/web/app/app.voidhash.com/(auth)/login/loading.tsx +++ b/apps/web/app/app.voidhash.com/(auth)/login/loading.tsx @@ -1,9 +1,9 @@ -import { Spinner } from "@voidhash/ui"; +import { Spinner } from '@voidhash/ui'; export default function LoginLoading() { - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/apps/web/app/app.voidhash.com/(auth)/login/page.tsx b/apps/web/app/app.voidhash.com/(auth)/login/page.tsx index 658443dd7..0125919dd 100644 --- a/apps/web/app/app.voidhash.com/(auth)/login/page.tsx +++ b/apps/web/app/app.voidhash.com/(auth)/login/page.tsx @@ -1,142 +1,147 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { authClient } from "@voidhash/auth/client"; +import { authClient } from '@voidhash/auth/client'; import { - Logo, - Alert, - AlertTitle, - AlertDescription, - Label, - Input, - Button, -} from "@voidhash/ui"; -import { CheckCircle } from "lucide-react"; -import { toast } from "sonner"; -import Link from "next/link"; + Alert, + AlertDescription, + AlertTitle, + Button, + Input, + Label, + Logo +} from '@voidhash/ui'; +import { CheckCircle } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; // import { LoginPageIllustration } from "@/features/auth/components/login-page-illustration"; export default function LoginPage() { - const searchParams = useSearchParams(); - const router = useRouter(); + const searchParams = useSearchParams(); + const router = useRouter(); - const [email, setEmail] = useState(searchParams.get("email") || ""); - const [password, setPassword] = useState(""); - const [loading, setLoading] = useState(false); + const [email, setEmail] = useState(searchParams.get('email') || ''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); - const signIn = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - const { error } = await authClient.signIn.email({ - email: email, - password: password, - }); + const signIn = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + const { error } = await authClient.signIn.email({ + email, + password + }); - if (error) { - toast.error(error.message ?? "An unknown error occurred"); - setLoading(false); - return; - } + if (error) { + toast.error(error.message ?? 'An unknown error occurred'); + setLoading(false); + return; + } - const next = searchParams.get("next"); - if (next && next.length > 1) { - router.refresh(); - router.push(decodeURIComponent(next)); - } else { - router.refresh(); - router.push("/"); - } - }; + const next = searchParams.get('next'); + if (next && next.length > 1) { + router.refresh(); + router.push(decodeURIComponent(next)); + } else { + router.refresh(); + router.push('/'); + } + }; - return ( -
-
-
- - - -
-
-
-
- {searchParams.get("signup") === "true" && ( -
- - - - Your account was successfully created - - - You can now login to your account - - -
- )} -
-

Login to your account

-

- Enter your email below to login to your account -

-
-
-
- -
-
- - Or continue with - -
-
- - setEmail(e.target.value)} - /> -
-
-
- -
- setPassword(e.target.value)} - /> -
- -
-
- Don't have an account?{" "} - - Sign up - -
-
-
-
-
-
- {/* */} -
-
- ); + return ( +
+
+
+ + + +
+
+
+
+ {searchParams.get('signup') === 'true' && ( +
+ + + + Your account was successfully created + + + You can now login to your account + + +
+ )} +
+

Login to your account

+

+ Enter your email below to login to your account +

+
+
+
+ +
+
+ + Or continue with + +
+
+ + setEmail(e.target.value)} + placeholder="name@example.com" + required + type="email" + value={email} + /> +
+
+
+ +
+ setPassword(e.target.value)} + required + type="password" + value={password} + /> +
+ +
+
+ Don't have an account?{' '} + + Sign up + +
+
+
+
+
+
+ {/* */} +
+
+ ); } diff --git a/apps/web/app/app.voidhash.com/(auth)/sign-up/page.tsx b/apps/web/app/app.voidhash.com/(auth)/sign-up/page.tsx index bf4e6dc1f..b73c84aa2 100644 --- a/apps/web/app/app.voidhash.com/(auth)/sign-up/page.tsx +++ b/apps/web/app/app.voidhash.com/(auth)/sign-up/page.tsx @@ -1,175 +1,175 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; +import { zodResolver } from '@hookform/resolvers/zod'; +import { authClient } from '@voidhash/auth/client'; import { - Logo, - Input, - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui"; -import { toast } from "sonner"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { authClient } from "@voidhash/auth/client"; + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Logo +} from '@voidhash/ui'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; const signUpSchema = z - .object({ - name: z.string().min(2, "Name must be at least 2 characters"), - email: z.string().email("Please enter a valid email address"), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, - "Password must contain at least one uppercase letter, one lowercase letter, and one number" - ), - confirmPassword: z.string(), - }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords do not match", - path: ["confirmPassword"], - }); + .object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + email: z.string().email('Please enter a valid email address'), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, + 'Password must contain at least one uppercase letter, one lowercase letter, and one number' + ), + confirmPassword: z.string() + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'] + }); type SignUpForm = z.infer; export default function SignUpPage() { - const [loading, setLoading] = useState(false); - const router = useRouter(); + const [loading, setLoading] = useState(false); + const router = useRouter(); - const form = useForm({ - resolver: zodResolver(signUpSchema), - defaultValues: { - name: "", - email: "", - password: "", - confirmPassword: "", - }, - }); + const form = useForm({ + resolver: zodResolver(signUpSchema), + defaultValues: { + name: '', + email: '', + password: '', + confirmPassword: '' + } + }); - const onSubmit = async (data: SignUpForm) => { - setLoading(true); - const { error } = await authClient.signUp.email({ - email: data.email, - password: data.password, - name: data.name, - }); + const onSubmit = async (data: SignUpForm) => { + setLoading(true); + const { error } = await authClient.signUp.email({ + email: data.email, + password: data.password, + name: data.name + }); - if (error) { - toast.error(error.message ?? "An unknown error occurred"); - setLoading(false); - return; - } + if (error) { + toast.error(error.message ?? 'An unknown error occurred'); + setLoading(false); + return; + } - router.push(`/login?email=${encodeURIComponent(data.email)}&signup=true`); - setLoading(false); - }; + router.push(`/login?email=${encodeURIComponent(data.email)}&signup=true`); + setLoading(false); + }; - return ( -
-
-
-
- - - -
- - - Create an Account - - Enter your details below to create your account - - - -
- - ( - - Name - - - - - - )} - /> - ( - - Email - - - - - - )} - /> - ( - - Password - - - - - - )} - /> - ( - - Confirm Password - - - - - - )} - /> - - - -
- Already have an account?{" "} - - Login - -
-
-
-
-
-
- ); + return ( +
+
+
+
+ + + +
+ + + Create an Account + + Enter your details below to create your account + + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Confirm Password + + + + + + )} + /> + + + +
+ Already have an account?{' '} + + Login + +
+
+
+
+
+
+ ); } diff --git a/apps/web/app/app.voidhash.com/(create-organisation)/~/create-organization/page.tsx b/apps/web/app/app.voidhash.com/(create-organisation)/~/create-organization/page.tsx index 34aedd4ea..14c5da69d 100644 --- a/apps/web/app/app.voidhash.com/(create-organisation)/~/create-organization/page.tsx +++ b/apps/web/app/app.voidhash.com/(create-organisation)/~/create-organization/page.tsx @@ -1,132 +1,133 @@ -"use client"; +'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQueryClient } from '@tanstack/react-query'; +import { authClient } from '@voidhash/auth/client'; import { - Logo, - Input, - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui"; -import { toast } from "sonner"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { useAction } from "next-safe-action/hooks"; -import { authClient } from "@voidhash/auth/client"; -import { createOrganizationAction } from "@/lib/nextjs/server-actions"; + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Logo +} from '@voidhash/ui'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { createOrganizationAction } from '@/lib/nextjs/server-actions'; const createOrganizationSchema = z.object({ - name: z - .string() - .min(1, "Organization name is required") - .max(32, "Organization name must be less than 32 characters"), + name: z + .string() + .min(1, 'Organization name is required') + .max(32, 'Organization name must be less than 32 characters') }); type CreateOrganizationForm = z.infer; export default function CreateOrgPage() { - const router = useRouter(); + const router = useRouter(); - const form = useForm({ - resolver: zodResolver(createOrganizationSchema), - defaultValues: { - name: "", - }, - }); + const form = useForm({ + resolver: zodResolver(createOrganizationSchema), + defaultValues: { + name: '' + } + }); - const queryClient = useQueryClient(); + const queryClient = useQueryClient(); - const { execute, isPending } = useAction(createOrganizationAction, { - onSuccess: (res) => { - queryClient.invalidateQueries(); - router.push(`/${res.data?.slug}`); - }, - onError: (error) => { - toast.error(error.error.serverError); - }, - }); + const { execute, isPending } = useAction(createOrganizationAction, { + onSuccess: (res) => { + queryClient.invalidateQueries(); + router.push(`/${res.data?.slug}`); + }, + onError: (error) => { + toast.error(error.error.serverError); + } + }); - const onSubmit = async (data: CreateOrganizationForm) => { - execute(data); - }; + const onSubmit = (data: CreateOrganizationForm) => { + execute(data); + }; - // Sign out - const signOut = async () => { - await authClient.signOut(); - router.refresh(); - router.push("/"); - }; + // Sign out + const signOut = async () => { + await authClient.signOut(); + router.refresh(); + router.push('/'); + }; - return ( -
-
-
-
- - - -
- - - Welcome to Voidhash - - Let's start by creating your new team. - - - -
- - ( - - Team Name - - - - - - )} - /> - - - -
-

- Already have a team? Ask your team administrator to invite you - using your email address. -

-
-
-
-
- Signed in to a wrong account?{" "} - -
-
-
-
- ); + return ( +
+
+
+
+ + + +
+ + + Welcome to Voidhash + + Let's start by creating your new team. + + + +
+ + ( + + Team Name + + + + + + )} + /> + + + +
+

+ Already have a team? Ask your team administrator to invite you + using your email address. +

+
+
+
+
+ Signed in to a wrong account?{' '} + +
+
+
+
+ ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/layout-sidebar.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/layout-sidebar.tsx index 56c89f61a..81d14b49e 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/layout-sidebar.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/layout-sidebar.tsx @@ -1,32 +1,32 @@ -"use client"; +'use client'; -import { useSidebar } from "@voidhash/ui"; -import { usePathname } from "next/navigation"; -import { useEffect } from "react"; +import { useSidebar } from '@voidhash/ui'; +import { usePathname } from 'next/navigation'; +import { useEffect } from 'react'; export function LayoutSidebar({ - organizationSidebar, - organizationSettingsSidebar, + organizationSidebar, + organizationSettingsSidebar }: { - organizationSidebar: React.ReactNode; - organizationSettingsSidebar: React.ReactNode; + organizationSidebar: React.ReactNode; + organizationSettingsSidebar: React.ReactNode; }) { - const pathname = usePathname(); - const isSettingsRoute = pathname.includes("/settings"); + const pathname = usePathname(); + const isSettingsRoute = pathname.includes('/settings'); - const { setOpen } = useSidebar(); - useEffect(() => { - if (isSettingsRoute) { - setOpen(false); - } else if (!isSettingsRoute) { - setOpen(true); - } - }, [isSettingsRoute, setOpen]); + const { setOpen } = useSidebar(); + useEffect(() => { + if (isSettingsRoute) { + setOpen(false); + } else if (!isSettingsRoute) { + setOpen(true); + } + }, [isSettingsRoute, setOpen]); - return ( -
- {organizationSidebar} - {isSettingsRoute && organizationSettingsSidebar} -
- ); + return ( +
+ {organizationSidebar} + {isSettingsRoute && organizationSettingsSidebar} +
+ ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/layout.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/layout.tsx index 6cd433063..e519b9512 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/layout.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/layout.tsx @@ -1,87 +1,87 @@ -import { SidebarInset } from "@voidhash/ui"; -import { OrganizationSidebar } from "@/features/shell/organization-sidebar"; -import { OrganizationSettingsSidebar } from "@/features/shell/organization-settings-sidebar"; -import { LayoutSidebar } from "./layout-sidebar"; -import { NavBar } from "@/features/shell"; -import { Suspense } from "react"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { Effect } from "effect"; -import { ProjectService } from "@/lib/services/project.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { SidebarInset } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { Suspense } from 'react'; +import { NavBar } from '@/features/shell'; +import { OrganizationSettingsSidebar } from '@/features/shell/organization-settings-sidebar'; +import { OrganizationSidebar } from '@/features/shell/organization-sidebar'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { LayoutSidebar } from './layout-sidebar'; async function OrganizationSettingsLayoutSidebar({ - organizationSlug, + organizationSlug }: { - organizationSlug: string; + organizationSlug: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const projects = - yield* projectService.getProjectsByOrganizationSlug( - organizationSlug - ); - return { projects }; - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const projects = + yield* projectService.getProjectsByOrganizationSlug( + organizationSlug + ); + return { projects }; + }) + ); + }) + ); - if (data.isErr()) { - return null; - } + if (data.isErr()) { + return null; + } - const { projects } = data.value; + const { projects } = data.value; - return ( - - ); + return ( + + ); } export default async function OrganizationLayout({ - children, - params, + children, + params }: { - children: React.ReactNode; - params: Promise<{ organizationSlug: string }>; + children: React.ReactNode; + params: Promise<{ organizationSlug: string }>; }) { - const { organizationSlug } = await params; + const { organizationSlug } = await params; - return ( - <> - + return ( + <> + -
- - } - organizationSettingsSidebar={ - - } - > - - - } - /> - - {children} - -
- - ); +
+ + } + > + + + } + organizationSidebar={ + + } + /> + + {children} + +
+ + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/page.tsx index f0cb4e1bb..16890a9fa 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/page.tsx @@ -1,13 +1,13 @@ -import { ProjectsPage } from "@/features/organizations/projects/projects-page"; +import { ProjectsPage } from '@/features/organizations/projects/projects-page'; export default async function RouteComponent({ - params, + params }: { - params: Promise<{ - organizationSlug: string; - }>; + params: Promise<{ + organizationSlug: string; + }>; }) { - const { organizationSlug } = await params; + const { organizationSlug } = await params; - return ; + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/~/settings/general/loading.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/~/settings/general/loading.tsx index 0cfa7a227..95d9355b1 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/~/settings/general/loading.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/~/settings/general/loading.tsx @@ -1,5 +1,5 @@ -import { SettingsGeneralPageSkeleton } from "@/features/organizations/settings/general/settings-general-page-skeleton"; +import { SettingsGeneralPageSkeleton } from '@/features/organizations/settings/general/settings-general-page-skeleton'; -export default function GeneralSettingsPage({}) { - return ; +export default function GeneralSettingsPage() { + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/~/settings/general/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/~/settings/general/page.tsx index 450673e72..cacadb9fe 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/~/settings/general/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(organization)/[organizationSlug]/~/settings/general/page.tsx @@ -1,9 +1,11 @@ -import SettingsGeneralPage from "@/features/organizations/settings/general/settings-general-page"; +import SettingsGeneralPage from '@/features/organizations/settings/general/settings-general-page'; export default async function Page({ - params, -}: { params: Promise<{ organizationSlug: string }> }) { - const { organizationSlug } = await params; + params +}: { + params: Promise<{ organizationSlug: string }>; +}) { + const { organizationSlug } = await params; - return ; + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/(environment)/environment-redirect/route.ts b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/(environment)/environment-redirect/route.ts index afc1b6ea1..0e2517a9c 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/(environment)/environment-redirect/route.ts +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/(environment)/environment-redirect/route.ts @@ -1,32 +1,32 @@ -import { setEnvironment } from "@/lib/core/environments/utils"; -import { NextCookiesAdapter } from "@/lib/nextjs/utils/next-cookies-adapter"; -import { redirect } from "next/navigation"; -import { NextRequest } from "next/server"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/index"; +import { Environment as EnvironmentEnum } from '@voidhash/lib/index'; +import { redirect } from 'next/navigation'; +import type { NextRequest } from 'next/server'; +import { setEnvironment } from '@/lib/core/environments/utils'; +import { NextCookiesAdapter } from '@/lib/nextjs/utils/next-cookies-adapter'; export async function GET( - request: NextRequest, - { - params, - }: { - params: Promise<{ organizationSlug: string; projectSlug: string }>; - } + request: NextRequest, + { + params + }: { + params: Promise<{ organizationSlug: string; projectSlug: string }>; + } ) { - const searchParams = request.nextUrl.searchParams; - const { organizationSlug, projectSlug } = await params; + const searchParams = request.nextUrl.searchParams; + const { organizationSlug, projectSlug } = await params; - await setEnvironment( - new NextCookiesAdapter(), - organizationSlug, - projectSlug, - EnvironmentEnum.Production - ); + await setEnvironment( + new NextCookiesAdapter(), + organizationSlug, + projectSlug, + EnvironmentEnum.Production + ); - const next = searchParams.get("next"); + const next = searchParams.get('next'); - if (next) { - redirect(decodeURIComponent(next)); - } else { - redirect("/"); - } + if (next) { + redirect(decodeURIComponent(next)); + } else { + redirect('/'); + } } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/customers/[id]/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/customers/[id]/page.tsx index 3a3da5137..b36020fd2 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/customers/[id]/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/customers/[id]/page.tsx @@ -1,20 +1,20 @@ -import { CustomerDetailPage } from "@/features/customers/customers-detail-page"; +import { CustomerDetailPage } from '@/features/customers/customers-detail-page'; export default async function CustomerPage({ - params, + params }: { - params: Promise<{ - id: string; - organizationSlug: string; - projectSlug: string; - }>; + params: Promise<{ + id: string; + organizationSlug: string; + projectSlug: string; + }>; }) { - const { id, organizationSlug, projectSlug } = await params; - return ( - - ); + const { id, organizationSlug, projectSlug } = await params; + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/customers/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/customers/page.tsx index 619bf5010..a633713dd 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/customers/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/customers/page.tsx @@ -1,15 +1,15 @@ -import { CustomersPage } from "@/features/customers/customers-page"; +import { CustomersPage } from '@/features/customers/customers-page'; export default async function Page({ - params, + params }: { - params: { organizationSlug: string; projectSlug: string }; + params: { organizationSlug: string; projectSlug: string }; }) { - const { organizationSlug, projectSlug } = await params; - return ( - - ); + const { organizationSlug, projectSlug } = await params; + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/api-keys/loading.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/api-keys/loading.tsx index 20c468063..71e0c7aa5 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/api-keys/loading.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/api-keys/loading.tsx @@ -1,5 +1,5 @@ -import { ProjectApiKeysPageSkeleton } from "@/features/api-keys/project-api-keys-page-skeleton"; +import { ProjectApiKeysPageSkeleton } from '@/features/api-keys/project-api-keys-page-skeleton'; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/api-keys/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/api-keys/page.tsx index b6adf7334..55c665746 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/api-keys/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/api-keys/page.tsx @@ -1,14 +1,16 @@ -import { ProjectApiKeysPage } from "@/features/api-keys/project-api-keys-page"; +import { ProjectApiKeysPage } from '@/features/api-keys/project-api-keys-page'; export default async function Page({ - params, -}: { params: Promise<{ organizationSlug: string; projectSlug: string }> }) { - const { organizationSlug, projectSlug } = await params; + params +}: { + params: Promise<{ organizationSlug: string; projectSlug: string }>; +}) { + const { organizationSlug, projectSlug } = await params; - return ( - - ); + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/layout.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/layout.tsx index 9fcc15af2..66ce47778 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/layout.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/layout.tsx @@ -1,56 +1,56 @@ -import { DevelopersTabBar } from "@/features/developers/developers-tab-bar"; -import { Page } from "@/features/shell"; +import { DevelopersTabBar } from '@/features/developers/developers-tab-bar'; +import { Page } from '@/features/shell'; export default async function DevelopersLayout({ - children, - params, + children, + params }: { - children: React.ReactNode; - params: Promise<{ - organizationSlug: string; - projectSlug: string; - }>; + children: React.ReactNode; + params: Promise<{ + organizationSlug: string; + projectSlug: string; + }>; }) { - const { organizationSlug, projectSlug } = await params; + const { organizationSlug, projectSlug } = await params; - const tabs = [ - { - label: "Overview", - path: `/${organizationSlug}/${projectSlug}/developers`, - }, - { - label: "API Keys", - path: `/${organizationSlug}/${projectSlug}/developers/api-keys`, - }, - { - label: "Paywall Locations", - path: `/${organizationSlug}/${projectSlug}/developers/paywall-locations`, - }, - { - label: "Perks", - path: `/${organizationSlug}/${projectSlug}/developers/perks`, - }, - { - label: "Webhooks", - path: `/${organizationSlug}/${projectSlug}/developers/webhooks`, - }, - ]; + const tabs = [ + { + label: 'Overview', + path: `/${organizationSlug}/${projectSlug}/developers` + }, + { + label: 'API Keys', + path: `/${organizationSlug}/${projectSlug}/developers/api-keys` + }, + // { + // label: 'Paywall Locations', + // path: `/${organizationSlug}/${projectSlug}/developers/paywall-locations` + // }, + { + label: 'Perks', + path: `/${organizationSlug}/${projectSlug}/developers/perks` + }, + { + label: 'Webhooks', + path: `/${organizationSlug}/${projectSlug}/developers/webhooks` + } + ]; - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} + return ( + + {/* Key is used to reload the default form data when the organization slug changes */} -
-

Developers

-
- {/*

+

+

Developers

+
+ {/*

List of products available to purchase.

*/} -
- - {children} -
-
- ); +
+ + {children} +
+
+ ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/page.tsx index 9932a5aab..f3e6aab71 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/page.tsx @@ -1,15 +1,15 @@ -import { DevelopersPage } from "@/features/developers/developers-page"; +import { DevelopersPage } from '@/features/developers/developers-page'; export default async function Page({ - params, + params }: { - params: { organizationSlug: string; projectSlug: string }; + params: { organizationSlug: string; projectSlug: string }; }) { - const { organizationSlug, projectSlug } = await params; - return ( - - ); + const { organizationSlug, projectSlug } = await params; + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/paywall-locations/loading.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/paywall-locations/loading.tsx index ac9834274..52c7d41a3 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/paywall-locations/loading.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/paywall-locations/loading.tsx @@ -1,5 +1,5 @@ -import { PaywallLocationsPageSkeleton } from "@/features/paywall-locations/paywall-locations-page-skeleton"; +import { PaywallLocationsPageSkeleton } from '@/features/paywall-locations/paywall-locations-page-skeleton'; export default function PaywallLocationsLoading() { - return ; + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/paywall-locations/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/paywall-locations/page.tsx index 7d4b4942b..b00fbfbdd 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/paywall-locations/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/paywall-locations/page.tsx @@ -1,18 +1,18 @@ -import { PaywallLocationsPage } from "@/features/paywall-locations/paywall-locations-page"; +import { PaywallLocationsPage } from '@/features/paywall-locations/paywall-locations-page'; export default async function Page({ - params, + params }: { - params: Promise<{ - organizationSlug: string; - projectSlug: string; - }>; + params: Promise<{ + organizationSlug: string; + projectSlug: string; + }>; }) { - const { organizationSlug, projectSlug } = await params; - return ( - - ); + const { organizationSlug, projectSlug } = await params; + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/perks/loading.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/perks/loading.tsx index d3000f93f..98f8d3de3 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/perks/loading.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/perks/loading.tsx @@ -1,5 +1,5 @@ -import { PerksPageSkeleton } from "@/features/perks/perks-page-skeleton"; +import { PerksPageSkeleton } from '@/features/perks/perks-page-skeleton'; export default function PerksLoading() { - return ; + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/perks/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/perks/page.tsx index f1fee1a37..7888b7b14 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/perks/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/developers/perks/page.tsx @@ -1,15 +1,15 @@ -import { PerksPage } from "@/features/perks/perks-page"; +import { PerksPage } from '@/features/perks/perks-page'; export default async function Page({ - params, + params }: { - params: Promise<{ - organizationSlug: string; - projectSlug: string; - }>; + params: Promise<{ + organizationSlug: string; + projectSlug: string; + }>; }) { - const { organizationSlug, projectSlug } = await params; - return ( - - ); + const { organizationSlug, projectSlug } = await params; + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/layout-sidebar.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/layout-sidebar.tsx index d4bb614b5..1adac3eba 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/layout-sidebar.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/layout-sidebar.tsx @@ -1,32 +1,32 @@ -"use client"; +'use client'; -import { useSidebar } from "@voidhash/ui"; -import { usePathname } from "next/navigation"; -import { useEffect } from "react"; +import { useSidebar } from '@voidhash/ui'; +import { usePathname } from 'next/navigation'; +import { useEffect } from 'react'; export function LayoutSidebar({ - projectSidebar, - projectSettingsSidebar, + projectSidebar, + projectSettingsSidebar }: { - projectSidebar: React.ReactNode; - projectSettingsSidebar: React.ReactNode; + projectSidebar: React.ReactNode; + projectSettingsSidebar: React.ReactNode; }) { - const pathname = usePathname(); - const isSettingsRoute = pathname.includes("/settings"); + const pathname = usePathname(); + const isSettingsRoute = pathname.includes('/settings'); - const { setOpen } = useSidebar(); - useEffect(() => { - if (isSettingsRoute) { - setOpen(false); - } else if (!isSettingsRoute) { - setOpen(true); - } - }, [isSettingsRoute, setOpen]); + const { setOpen } = useSidebar(); + useEffect(() => { + if (isSettingsRoute) { + setOpen(false); + } else if (!isSettingsRoute) { + setOpen(true); + } + }, [isSettingsRoute, setOpen]); - return ( -
- {projectSidebar} - {isSettingsRoute && projectSettingsSidebar} -
- ); + return ( +
+ {projectSidebar} + {isSettingsRoute && projectSettingsSidebar} +
+ ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/layout.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/layout.tsx index 42a40131e..46f77b4eb 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/layout.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/layout.tsx @@ -1,100 +1,100 @@ -import { NavBar } from "@/features/shell"; -import { LayoutSidebar } from "./layout-sidebar"; -import { SidebarInset } from "@voidhash/ui"; -import { ProjectSidebar } from "@/features/shell/project-sidebar"; -import { ProjectSettingsSidebar } from "@/features/shell/project-settings-sidebar"; -import { Suspense } from "react"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { Effect } from "effect"; -import { OrganizationService } from "@/lib/services/organization.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { SidebarInset } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { Suspense } from 'react'; +import { NavBar } from '@/features/shell'; +import { ProjectSettingsSidebar } from '@/features/shell/project-settings-sidebar'; +import { ProjectSidebar } from '@/features/shell/project-sidebar'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { OrganizationService } from '@/lib/services/organization.service'; +import { LayoutSidebar } from './layout-sidebar'; async function ProjectLayoutSidebar({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string; - projectSlug: string; + organizationSlug: string; + projectSlug: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const activeOrganization = yield* organizationService - .getOrganizationBySlug(organizationSlug) - .pipe( - Effect.catchTags({ - OrganizationNotFound: () => Effect.succeed(null), - }), - ); - return { activeOrganization }; - }), - ); - }), - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const activeOrganizationRes = yield* organizationService + .getOrganizationBySlug(organizationSlug) + .pipe( + Effect.catchTags({ + OrganizationNotFound: () => Effect.succeed(null) + }) + ); + return { activeOrganization: activeOrganizationRes }; + }) + ); + }) + ); - if (data.isErr()) { - return null; - } + if (data.isErr()) { + return null; + } - const { activeOrganization } = data.value; + const { activeOrganization } = data.value; - return ( - - ); + return ( + + ); } export default async function ProjectLayout({ - children, - params, + children, + params }: { - children: React.ReactNode; - params: Promise<{ organizationSlug: string; projectSlug: string }>; + children: React.ReactNode; + params: Promise<{ organizationSlug: string; projectSlug: string }>; }) { - const { organizationSlug, projectSlug } = await params; + const { organizationSlug, projectSlug } = await params; - return ( - <> - + return ( + <> + -
- - } - projectSettingsSidebar={ - - } - > - - - } - /> - - {children} - -
- - ); +
+ + } + > + + + } + projectSidebar={ + + } + /> + + {children} + +
+ + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/page.tsx index 75b1b0205..b6ef3f124 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/page.tsx @@ -1,3 +1,3 @@ export default function ProjectPage() { - return
ProjectPage
; + return
ProjectPage
; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/[id]/loading.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/[id]/loading.tsx index b49e51a13..7d4523171 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/[id]/loading.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/[id]/loading.tsx @@ -1,5 +1,5 @@ -import { PaywallsDetailPageSkeleton } from "@/features/paywalls/paywalls-detail-page-skeleton"; +import { PaywallsDetailPageSkeleton } from '@/features/paywalls/paywalls-detail-page-skeleton'; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/[id]/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/[id]/page.tsx index 3d54c62d6..8013020b4 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/[id]/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/[id]/page.tsx @@ -1,21 +1,21 @@ -import { PaywallsDetailPage } from "@/features/paywalls/paywalls-detail-page"; +import { PaywallsDetailPage } from '@/features/paywalls/paywalls-detail-page'; export default async function Page({ - params, + params }: { - params: Promise<{ - organizationSlug: string; - projectSlug: string; - id: string; - }>; + params: Promise<{ + organizationSlug: string; + projectSlug: string; + id: string; + }>; }) { - const { organizationSlug, projectSlug, id } = await params; + const { organizationSlug, projectSlug, id } = await params; - return ( - - ); + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/loading.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/loading.tsx index 25bc5b439..8ecca0bd0 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/loading.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/loading.tsx @@ -1,5 +1,5 @@ -import { PaywallsPageSkeleton } from "@/features/paywalls/paywalls-page-skeleton"; +import { PaywallsPageSkeleton } from '@/features/paywalls/paywalls-page-skeleton'; export default function PaywallsLoading() { - return ; + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/page.tsx index 9ecea5242..680f3e126 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/paywalls/page.tsx @@ -1,18 +1,18 @@ -import { PaywallsPage } from "@/features/paywalls/paywalls-page"; +import { PaywallsPage } from '@/features/paywalls/paywalls-page'; export default async function Page({ - params, + params }: { - params: Promise<{ - organizationSlug: string; - projectSlug: string; - }>; + params: Promise<{ + organizationSlug: string; + projectSlug: string; + }>; }) { - const { organizationSlug, projectSlug } = await params; - return ( - - ); + const { organizationSlug, projectSlug } = await params; + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/[id]/loading.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/[id]/loading.tsx index ff9c609bb..515b74d63 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/[id]/loading.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/[id]/loading.tsx @@ -1,5 +1,5 @@ -import { ProductsDetailPageSkeleton } from "@/features/products/product-detail-page-skeleton"; +import { ProductsDetailPageSkeleton } from '@/features/products/product-detail-page-skeleton'; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/[id]/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/[id]/page.tsx index 09a87669a..3fc13482f 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/[id]/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/[id]/page.tsx @@ -1,20 +1,20 @@ -import { ProductDetailPage } from "@/features/products/product-detail-page"; +import { ProductDetailPage } from '@/features/products/product-detail-page'; export default async function Page({ - params, + params }: { - params: Promise<{ - organizationSlug: string; - projectSlug: string; - id: string; - }>; + params: Promise<{ + organizationSlug: string; + projectSlug: string; + id: string; + }>; }) { - const { organizationSlug, projectSlug, id } = await params; - return ( - - ); + const { organizationSlug, projectSlug, id } = await params; + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/loading.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/loading.tsx index 137158afc..791b29be5 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/loading.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/loading.tsx @@ -1,5 +1,5 @@ -import { ProductsPageSkeleton } from "@/features/products/products-page-skeleton"; +import { ProductsPageSkeleton } from '@/features/products/products-page-skeleton'; -export default async function Page() { - return ; +export default function Page() { + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/page.tsx index f62cfe1a1..ba3e04acc 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/products/page.tsx @@ -1,18 +1,18 @@ -import { ProductsPage } from "@/features/products/products-page"; +import { ProductsPage } from '@/features/products/products-page'; export default async function Page({ - params, + params }: { - params: Promise<{ - organizationSlug: string; - projectSlug: string; - }>; + params: Promise<{ + organizationSlug: string; + projectSlug: string; + }>; }) { - const { organizationSlug, projectSlug } = await params; - return ( - - ); + const { organizationSlug, projectSlug } = await params; + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/general/loading.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/general/loading.tsx index b805dcb05..1a2ce1df7 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/general/loading.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/general/loading.tsx @@ -1,5 +1,5 @@ -import { ProjectSettingsGeneralPageSkeleton } from "@/features/projects/settings/general/project-settings-general-page-skeleton"; +import { ProjectSettingsGeneralPageSkeleton } from '@/features/projects/settings/general/project-settings-general-page-skeleton'; -export default function GeneralSettingsPage({}) { - return ; +export default function GeneralSettingsPage() { + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/general/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/general/page.tsx index b73e1e7bb..8fe8103d4 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/general/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/general/page.tsx @@ -1,14 +1,16 @@ -import { ProjectSettingsGeneralPage } from "@/features/projects/settings/general/project-settings-general-page"; +import { ProjectSettingsGeneralPage } from '@/features/projects/settings/general/project-settings-general-page'; export default async function GeneralSettingsPage({ - params, -}: { params: Promise<{ organizationSlug: string; projectSlug: string }> }) { - const { organizationSlug, projectSlug } = await params; + params +}: { + params: Promise<{ organizationSlug: string; projectSlug: string }>; +}) { + const { organizationSlug, projectSlug } = await params; - return ( - - ); + return ( + + ); } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/[paymentProviderConfigurationId]/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/[paymentProviderConfigurationId]/page.tsx index 9716e5e5d..2c41739e1 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/[paymentProviderConfigurationId]/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/[paymentProviderConfigurationId]/page.tsx @@ -1,9 +1,5 @@ -import { PaymentProviderDetailPage } from "@/features/projects/settings/payment-providers/payment-provider-detail-page"; +import { PaymentProviderDetailPage } from '@/features/projects/settings/payment-providers/payment-provider-detail-page'; -export default async function Page({ - params, -}: { - params; -}) { - return ; +export default function Page({ params }: { params }) { + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/loading.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/loading.tsx index a6542501b..3727f45ea 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/loading.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/loading.tsx @@ -1,5 +1,5 @@ -import { PaymentProvidersPageSkeleton } from "@/features/projects/settings/payment-providers/payment-providers-page-skeleton"; +import { PaymentProvidersPageSkeleton } from '@/features/projects/settings/payment-providers/payment-providers-page-skeleton'; export default function Page() { - return ; + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/page.tsx b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/page.tsx index c48bbb576..b4a490abd 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/page.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/(project)/[organizationSlug]/[projectSlug]/settings/payment-providers/page.tsx @@ -1,9 +1,5 @@ -import { PaymentProvidersPage } from "@/features/projects/settings/payment-providers/payment-providers-page"; +import { PaymentProvidersPage } from '@/features/projects/settings/payment-providers/payment-providers-page'; -export default async function Page({ - params, -}: { - params; -}) { - return ; +export default function Page({ params }: { params }) { + return ; } diff --git a/apps/web/app/app.voidhash.com/(dashboard)/layout.tsx b/apps/web/app/app.voidhash.com/(dashboard)/layout.tsx index b4fd2d577..a4ed6ea29 100644 --- a/apps/web/app/app.voidhash.com/(dashboard)/layout.tsx +++ b/apps/web/app/app.voidhash.com/(dashboard)/layout.tsx @@ -1,51 +1,51 @@ -"use client"; +'use client'; -import { authClient } from "@voidhash/auth/client"; -import { Logo, SidebarProvider, useIsMobile } from "@voidhash/ui"; -import { usePathname } from "next/navigation"; -import { useRouter } from "next/navigation"; +import { authClient } from '@voidhash/auth/client'; +import { Logo, SidebarProvider, useIsMobile } from '@voidhash/ui'; +import { usePathname, useRouter } from 'next/navigation'; export default function DashboardLayout({ - children, + children }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const router = useRouter(); - const pathname = usePathname(); - const isSettingsRoute = pathname.includes("/settings"); - const isMobile = useIsMobile(); + const router = useRouter(); + const pathname = usePathname(); + const isSettingsRoute = pathname.includes('/settings'); + const isMobile = useIsMobile(); - const signOut = async () => { - await authClient.signOut(); - router.refresh(); - router.push("/"); - }; + const signOut = async () => { + await authClient.signOut(); + router.refresh(); + router.push('/'); + }; - return ( -
- - {children} - {isMobile && ( -
- -
- Voidhash is currently not available on mobile, -
-
- Please use a desktop browser to access Voidhash. We are working - hard to bring you the best experience on mobile. -
-
- -
-
- )} -
-
- ); + return ( +
+ + {children} + {isMobile && ( +
+ +
+ Voidhash is currently not available on mobile, +
+
+ Please use a desktop browser to access Voidhash. We are working + hard to bring you the best experience on mobile. +
+
+ +
+
+ )} +
+
+ ); } diff --git a/apps/web/app/app.voidhash.com/layout.tsx b/apps/web/app/app.voidhash.com/layout.tsx index f792374d5..48e99a42c 100644 --- a/apps/web/app/app.voidhash.com/layout.tsx +++ b/apps/web/app/app.voidhash.com/layout.tsx @@ -1,9 +1,9 @@ -import { AppProviders } from "./providers"; +import { AppProviders } from './providers'; export default function AppLayout({ - children, + children }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return {children}; + return {children}; } diff --git a/apps/web/app/app.voidhash.com/page.tsx b/apps/web/app/app.voidhash.com/page.tsx index d099eea08..44040ecc9 100644 --- a/apps/web/app/app.voidhash.com/page.tsx +++ b/apps/web/app/app.voidhash.com/page.tsx @@ -1,47 +1,47 @@ -import { redirect } from "next/navigation"; -import { ErrorCard } from "@voidhash/ui"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { Effect } from "effect"; -import { UserService } from "@/lib/services/user.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { ErrorCard } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { redirect } from 'next/navigation'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { UserService } from '@/lib/services/user.service'; export default async function Index() { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const userService = yield* UserService; - const user = yield* userService.getUser(); - return { user }; - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const userService = yield* UserService; + const user = yield* userService.getUser(); + return { user }; + }) + ); + }) + ); - if (data.isErr()) { - const err = data._unsafeUnwrapErr(); + if (data.isErr()) { + const err = data._unsafeUnwrapErr(); - if (err.code === "NOT_FOUND" || err.code === "UNAUTHORIZED") { - return redirect("/login"); - } + if (err.code === 'NOT_FOUND' || err.code === 'UNAUTHORIZED') { + return redirect('/login'); + } - return ( - { - window.location.reload(); - }} - /> - ); - } + return ( + { + window.location.reload(); + }} + title="Something went wrong!" + /> + ); + } - const { user } = data.value; + const { user } = data.value; - if (user.organizations.length === 0) { - return redirect("/~/create-organization"); - } - return redirect(`/${user.organizations[0]!.slug}`); + if (user.organizations.length === 0) { + return redirect('/~/create-organization'); + } + return redirect(`/${user.organizations[0]?.slug}`); } diff --git a/apps/web/app/app.voidhash.com/providers.tsx b/apps/web/app/app.voidhash.com/providers.tsx index 624f8825c..51a139554 100644 --- a/apps/web/app/app.voidhash.com/providers.tsx +++ b/apps/web/app/app.voidhash.com/providers.tsx @@ -1,18 +1,18 @@ -"use client"; -import { TRPCReactProvider } from "@/features/trpc/react"; -import { Toaster } from "@voidhash/ui"; -import { Next13ProgressBar } from "next13-progressbar"; +'use client'; +import { Toaster } from '@voidhash/ui'; +import { Next13ProgressBar } from 'next13-progressbar'; +import { TRPCReactProvider } from '@/features/trpc/react'; export function AppProviders({ children }: { children: React.ReactNode }) { - return ( - - {children} - - - - ); + return ( + + {children} + + + + ); } diff --git a/apps/web/app/checkout.voidhash.com/dev-checkout/[checkoutSessionId]/checkout-buttons.tsx b/apps/web/app/checkout.voidhash.com/dev-checkout/[checkoutSessionId]/checkout-buttons.tsx index 504d9c5ed..1ab7e1b4d 100644 --- a/apps/web/app/checkout.voidhash.com/dev-checkout/[checkoutSessionId]/checkout-buttons.tsx +++ b/apps/web/app/checkout.voidhash.com/dev-checkout/[checkoutSessionId]/checkout-buttons.tsx @@ -1,65 +1,64 @@ -"use client"; +'use client'; +import { Button } from '@voidhash/ui'; +import { useAction } from 'next-safe-action/hooks'; +import { toast } from 'sonner'; import { - cancelDevCheckoutPurchaseAction , - confirmDevCheckoutPurchaseAction, -} from "@/lib/nextjs/server-actions"; -import { Button } from "@voidhash/ui"; -import { useAction } from "next-safe-action/hooks"; + cancelDevCheckoutPurchaseAction, + confirmDevCheckoutPurchaseAction +} from '@/lib/nextjs/server-actions'; export function CheckoutButtons({ - checkoutSessionId, + checkoutSessionId }: { - checkoutSessionId: string; + checkoutSessionId: string; }) { - const { execute: handleConfirm, isExecuting: isConfirming } = useAction( - confirmDevCheckoutPurchaseAction, - { - onSuccess: (data) => { - window.location.replace( - `${data?.data ?? ""}?checkoutSessionId=${checkoutSessionId}&success=true` - ); - }, - onError: (error) => { - console.log(error); - }, - } - ); + const { execute: handleConfirm, isExecuting: isConfirming } = useAction( + confirmDevCheckoutPurchaseAction, + { + onSuccess: (data) => { + window.location.replace( + `${data?.data ?? ''}?checkoutSessionId=${checkoutSessionId}&success=true` + ); + }, + onError: (error) => { + toast.error(error.error.serverError ?? 'An error occurred'); + } + } + ); - const { execute: handleCancel, isExecuting: isCancelling } = useAction( - cancelDevCheckoutPurchaseAction, - { - onSuccess: (data) => { - window.location.replace( - `${data?.data ?? ""}?checkoutSessionId=${checkoutSessionId}&error=cancelled` - ); - }, - onError: (error) => { - console.log(error); - }, - } - ); + const { execute: handleCancel, isExecuting: isCancelling } = useAction( + cancelDevCheckoutPurchaseAction, + { + onSuccess: (data) => { + window.location.replace( + `${data?.data ?? ''}?checkoutSessionId=${checkoutSessionId}&error=cancelled` + ); + }, + onError: (error) => { + toast.error(error.error.serverError ?? 'An error occurred'); + } + } + ); + return ( + <> + - - return ( - <> - - - - - ); + + + ); } diff --git a/apps/web/app/checkout.voidhash.com/dev-checkout/[checkoutSessionId]/page.tsx b/apps/web/app/checkout.voidhash.com/dev-checkout/[checkoutSessionId]/page.tsx index f02664340..9d58756b7 100644 --- a/apps/web/app/checkout.voidhash.com/dev-checkout/[checkoutSessionId]/page.tsx +++ b/apps/web/app/checkout.voidhash.com/dev-checkout/[checkoutSessionId]/page.tsx @@ -1,47 +1,47 @@ -import { checkoutSessions, db, eq } from "@voidhash/db"; -import { Card, Logo } from "@voidhash/ui"; -import { CheckoutButtons } from "./checkout-buttons"; +import { checkoutSessions, db, eq } from '@voidhash/db'; +import { Card, Logo } from '@voidhash/ui'; +import { CheckoutButtons } from './checkout-buttons'; export default async function CheckoutPage({ - params, + params }: { - params: Promise<{ checkoutSessionId: string }>; + params: Promise<{ checkoutSessionId: string }>; }) { - const { checkoutSessionId } = await params; + const { checkoutSessionId } = await params; - const checkoutSession = await db.query.checkoutSessions.findFirst({ - where: eq(checkoutSessions.id, checkoutSessionId), - with: { - paymentProviderConfigurationProduct: { - with: { - product: true, - }, - }, - }, - }); + const checkoutSession = await db.query.checkoutSessions.findFirst({ + where: eq(checkoutSessions.id, checkoutSessionId), + with: { + paymentProviderConfigurationProduct: { + with: { + product: true + } + } + } + }); - if (!checkoutSession) { - return
Checkout session not found
; - } + if (!checkoutSession) { + return
Checkout session not found
; + } - return ( -
- - -

Purchase (Test)

-
-

- You are about to purchase{" "} - - {checkoutSession.paymentProviderConfigurationProduct.product.name} - -

-
-

- You won't be charged for this purchase. -

- -
-
- ); + return ( +
+ + +

Purchase (Test)

+
+

+ You are about to purchase{' '} + + {checkoutSession.paymentProviderConfigurationProduct.product.name} + +

+
+

+ You won't be charged for this purchase. +

+ +
+
+ ); } diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index 25a57fbe8..0b08f8612 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -1,39 +1,36 @@ -"use client"; // Error boundaries must be Client Components +'use client'; // Error boundaries must be Client Components -import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { ErrorCard } from "@voidhash/ui"; -export default function Error({ - error, - reset, +import { ErrorCard } from '@voidhash/ui'; +import { usePathname, useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +export default function ErrorRender({ + error, + reset }: { - error: Error & { digest?: string }; - reset: () => void; + error: Error & { digest?: string }; + reset: () => void; }) { - const router = useRouter(); - const pathname = usePathname(); - const [initialized, setInitialized] = useState(false); - useEffect(() => { - console.log(error.name, typeof error.name); - if (error.name === "VoidhashError:UNAUTHORIZED" && pathname !== "/login") { - console.log("Redirecting to login"); - router.push("/login"); - } + const router = useRouter(); + const pathname = usePathname(); + const [initialized, setInitialized] = useState(false); + useEffect(() => { + if (error.name === 'VoidhashError:UNAUTHORIZED' && pathname !== '/login') { + router.push('/login'); + } - console.error(error); - setInitialized(true); - }, [error, pathname, router]); + setInitialized(true); + }, [error, pathname, router]); - if (!initialized) { - return
Loading...
; - } + if (!initialized) { + return
Loading...
; + } - return ( - reset()} - className="h-screen" - /> - ); + return ( + reset()} + title="Something went wrong!" + /> + ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 3067c6766..abaf0e79a 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/nursery/noUnknownAtRule: tailwind */ @import "tailwindcss"; @import "tw-animate-css"; @@ -8,167 +9,167 @@ @custom-variant dark (&:is(.dark *)); ::selection { - background: #0075f2; - color: #fff; + background: #0075f2; + color: #fff; } :root { - --background: oklch(0.985 0 0); - --foreground: oklch(0.145 0 0); - --surface: oklch(1 0 0); - --surface-muted: oklch(0.97 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - /* --primary: oklch(0.205 0 0); + --background: oklch(0.985 0 0); + --foreground: oklch(0.145 0 0); + --surface: oklch(1 0 0); + --surface-muted: oklch(0.97 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + /* --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); */ - --primary: oklch(0.55 0.25 261.78); - --primary-foreground: oklch(1.0 0 0); - --secondary: oklch(0.99 0 0); - --secondary-foreground: oklch(0.2 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.577 0.245 27.325); - --success: oklch(0.845 0.222 133); - --success-foreground: oklch(0.015 0 0); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --primary: oklch(0.55 0.25 261.78); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.99 0 0); + --secondary-foreground: oklch(0.2 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --success: oklch(0.845 0.222 133); + --success-foreground: oklch(0.015 0 0); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --surface: oklch(0.24 0 0); - --surface-muted: oklch(0.18 0 0); - --card: oklch(0.17 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.145 0 0); - --popover-foreground: oklch(0.985 0 0); - /* --primary: oklch(0.985 0 0); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --surface: oklch(0.24 0 0); + --surface-muted: oklch(0.18 0 0); + --card: oklch(0.17 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + /* --primary: oklch(0.985 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.205 0 0); --secondary-foreground: oklch(0.985 0 0); */ - --primary: oklch(0.55 0.25 261.78); - --primary-foreground: oklch(1.0 0 0); - --secondary: oklch(0.26 0 0); - --secondary-foreground: oklch(0.99 0 0); - --muted: oklch(0.205 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); - --success: oklch(0.845 0.222 133); - --success-foreground: oklch(0.015 0 0); - --border: oklch(0.269 0 0); - --input: oklch(0.269 0 0); - --ring: oklch(0.439 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.145 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.205 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(0.205 0 0); - --sidebar-ring: oklch(0.439 0 0); + --primary: oklch(0.55 0.25 261.78); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.26 0 0); + --secondary-foreground: oklch(0.99 0 0); + --muted: oklch(0.205 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --success: oklch(0.845 0.222 133); + --success-foreground: oklch(0.015 0 0); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.145 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.205 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.205 0 0); + --sidebar-ring: oklch(0.439 0 0); } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-surface: var(--surface); - --color-surface-muted: var(--surface-muted); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-success: var(--success); - --color-success-foreground: var(--success-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - --animate-accordion-down: accordion-down 0.2s ease-out; - --animate-accordion-up: accordion-up 0.2s ease-out; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-surface: var(--surface); + --color-surface-muted: var(--surface-muted); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; - @keyframes accordion-down { - from { - height: 0; - } - to { - height: var(--radix-accordion-content-height); - } - } + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - to { - height: 0; - } - } + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } } @layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } /* :root { diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 6fd7314ef..e0969899d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,43 +1,43 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; -import { ThemeProvider } from "@voidhash/ui"; +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import './globals.css'; +import { ThemeProvider } from '@voidhash/ui'; const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], + variable: '--font-geist-sans', + subsets: ['latin'] }); const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], + variable: '--font-geist-mono', + subsets: ['latin'] }); export const metadata: Metadata = { - title: "Voidhash - Payments made simple", - description: - "Voidhash is an open-source subscription management platform simplifying integrations, analytics, and revenue growth for apps and digital products.", + title: 'Voidhash - Payments made simple', + description: + 'Voidhash is an open-source subscription management platform simplifying integrations, analytics, and revenue growth for apps and digital products.' }; export default function RootLayout({ - children, + children }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - - - - {children} - - - - ); + return ( + + + + {children} + + + + ); } diff --git a/apps/web/app/manifest.json b/apps/web/app/manifest.json index 29bc99f34..b79da156c 100644 --- a/apps/web/app/manifest.json +++ b/apps/web/app/manifest.json @@ -18,4 +18,4 @@ "theme_color": "#ffffff", "background_color": "#ff3e03", "display": "standalone" -} \ No newline at end of file +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 6ee708e05..0d323deb6 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,5 +1,5 @@ -import { redirect } from "next/navigation"; +import { redirect } from 'next/navigation'; export default function Home() { - return redirect("/app.voidhash.com"); + return redirect('/app.voidhash.com'); } diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts index 534612d1d..37e5c356b 100644 --- a/apps/web/app/robots.ts +++ b/apps/web/app/robots.ts @@ -1,17 +1,17 @@ -import { MetadataRoute } from "next"; -import { headers } from "next/headers"; +import type { MetadataRoute } from 'next'; +import { headers } from 'next/headers'; export default async function robots(): Promise { - const headersList = await headers(); - const domain = headersList.get("host") as string; + const headersList = await headers(); + const domain = headersList.get('host') as string; - return { - rules: [ - { - userAgent: "*", - allow: "/", - }, - ], - sitemap: `https://${domain}/sitemap.xml`, - }; + return { + rules: [ + { + userAgent: '*', + allow: '/' + } + ], + sitemap: `https://${domain}/sitemap.xml` + }; } diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index d47a4e4ff..050e0b577 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -1,20 +1,20 @@ -import { MetadataRoute } from "next"; -import { headers } from "next/headers"; -import { SHORT_DOMAIN } from "@voidhash/lib"; +import { SHORT_DOMAIN } from '@voidhash/lib'; +import type { MetadataRoute } from 'next'; +import { headers } from 'next/headers'; export default async function sitemap(): Promise { - const headersList = await headers(); - let domain = headersList.get("host") as string; + const headersList = await headers(); + let domain = headersList.get('host') as string; - if (domain === "voidhash.localhost:3000" || domain.endsWith(".vercel.app")) { - // for local development and preview URLs - domain = SHORT_DOMAIN; - } + if (domain === 'voidhash.localhost:3000' || domain.endsWith('.vercel.app')) { + // for local development and preview URLs + domain = SHORT_DOMAIN; + } - return [ - { - url: `https://${domain}`, - lastModified: new Date(), - }, - ]; + return [ + { + url: `https://${domain}`, + lastModified: new Date() + } + ]; } diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 2046497c6..fba0bba17 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -1,49 +1,49 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; -import { defineConfig } from "eslint/config"; -// import neverthrowMustUse from "eslint-plugin-neverthrow-must-use"; // Import plugin -import tsPlugin from "@typescript-eslint/eslint-plugin"; // Import TypeScript plugin -import tsParser from "@typescript-eslint/parser"; // Import TypeScript parser +// import { dirname } from 'node:path'; +// import { fileURLToPath } from 'node:url'; +// import { FlatCompat } from '@eslint/eslintrc'; +// // import neverthrowMustUse from "eslint-plugin-neverthrow-must-use"; // Import plugin +// import tsPlugin from '@typescript-eslint/eslint-plugin'; // Import TypeScript plugin +// import tsParser from '@typescript-eslint/parser'; // Import TypeScript parser +// import { defineConfig } from 'eslint/config'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +// const __filename = fileURLToPath(import.meta.url); +// const __dirname = dirname(__filename); -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); +// const compat = new FlatCompat({ +// baseDirectory: __dirname, +// }); -const eslintConfig = defineConfig([ - ...compat.extends("next/core-web-vitals", "next/typescript"), - { - files: [ - "lib/**/*.ts", - "lib/**/*.tsx", - "app/**/*.ts", - "app/**/*.tsx", - "features/**/*.ts", - "features/**/*.tsx", - "jobs/**/*.ts", - "jobs/**/*.tsx", - ], - languageOptions: { - parser: tsParser, // Set TypeScript parser - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - project: "./tsconfig.json", // Ensure ESLint reads tsconfig - tsconfigRootDir: process.cwd(), - }, - }, - plugins: { - "@typescript-eslint": tsPlugin, // Enable TypeScript ESLint rules - // "neverthrow-must-use": neverthrowMustUse, // Register the plugin - }, - rules: { - "@typescript-eslint/no-unused-vars": "error", // Example TypeScript rule - // "neverthrow-must-use/must-use-result": "error", // Enforce `neverthrow` rule - }, - }, -]); +// const eslintConfig = defineConfig([ +// ...compat.extends('next/core-web-vitals', 'next/typescript'), +// { +// files: [ +// 'lib/**/*.ts', +// 'lib/**/*.tsx', +// 'app/**/*.ts', +// 'app/**/*.tsx', +// 'features/**/*.ts', +// 'features/**/*.tsx', +// 'jobs/**/*.ts', +// 'jobs/**/*.tsx', +// ], +// languageOptions: { +// parser: tsParser, // Set TypeScript parser +// parserOptions: { +// ecmaVersion: 'latest', +// sourceType: 'module', +// project: './tsconfig.json', // Ensure ESLint reads tsconfig +// tsconfigRootDir: process.cwd(), +// }, +// }, +// plugins: { +// '@typescript-eslint': tsPlugin, // Enable TypeScript ESLint rules +// // "neverthrow-must-use": neverthrowMustUse, // Register the plugin +// }, +// rules: { +// '@typescript-eslint/no-unused-vars': 'error', // Example TypeScript rule +// // "neverthrow-must-use/must-use-result": "error", // Enforce `neverthrow` rule +// }, +// }, +// ]); -export default eslintConfig; +// export default eslintConfig; diff --git a/apps/web/features/api-keys/api-key-record-skeleton.tsx b/apps/web/features/api-keys/api-key-record-skeleton.tsx index e5affaa1e..7b5a85060 100644 --- a/apps/web/features/api-keys/api-key-record-skeleton.tsx +++ b/apps/web/features/api-keys/api-key-record-skeleton.tsx @@ -1,21 +1,21 @@ -"use client"; +'use client'; -import { Skeleton } from "@voidhash/ui"; +import { Skeleton } from '@voidhash/ui'; export function ApiKeyRecordSkeleton() { - return ( -
-
-
-
- -
+ return ( +
+
+
+
+ +
-
- -
-
-
-
- ); +
+ +
+
+
+
+ ); } diff --git a/apps/web/features/api-keys/api-key-record.tsx b/apps/web/features/api-keys/api-key-record.tsx index a82155e05..5c5731595 100644 --- a/apps/web/features/api-keys/api-key-record.tsx +++ b/apps/web/features/api-keys/api-key-record.tsx @@ -1,163 +1,160 @@ -"use client"; +'use client'; +import type { ApiKey } from '@voidhash/db'; import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, - useConfirmDialog, -} from "@voidhash/ui"; -import { EllipsisVerticalIcon } from "lucide-react"; -import { useAction } from "next-safe-action/hooks"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; -import { SecretKeyRevealModal } from "./secret-key-reveal-modal"; + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + useConfirmDialog +} from '@voidhash/ui'; +import { EllipsisVerticalIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; import { - deleteSecretKeyAction, - rotateSecretKeyAction, -} from "@/lib/nextjs/server-actions"; -import type { ApiKey } from "@voidhash/db"; + deleteSecretKeyAction, + rotateSecretKeyAction +} from '@/lib/nextjs/server-actions'; +import { SecretKeyRevealModal } from './secret-key-reveal-modal'; -export function ApiKeyRecord({ - apiKey, -}: { - apiKey: ApiKey; -}) { - const router = useRouter(); - const [secretKey, setSecretKey] = useState(null); - const { ConfirmationDialog, openDialog } = useConfirmDialog(); +export function ApiKeyRecord({ apiKey }: { apiKey: ApiKey }) { + const router = useRouter(); + const [secretKey, setSecretKey] = useState(null); + const { ConfirmationDialog, openDialog } = useConfirmDialog(); - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - toast.success("Copied to clipboard"); - }; + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success('Copied to clipboard'); + }; - // Rotate key - const { execute: rotateKey, isExecuting: isRotating } = useAction( - rotateSecretKeyAction, - { - onSuccess: (res) => { - toast.success("Key successfully rotated"); - router.refresh(); - if (res.data) { - setSecretKey(res.data); - } - }, - onError: () => { - toast.error("Failed to rotate key"); - }, - } - ); - const handleRotateKey = async () => { - const res = await openDialog({ - title: "Rotate key", - description: - "Are you sure you want to rotate this key? It may break any services that are using it.", - confirmText: "Rotate", - }); - if (res) { - rotateKey({ - secretKeyId: apiKey.id, - }); - } - }; + // Rotate key + const { execute: rotateKey, isExecuting: isRotating } = useAction( + rotateSecretKeyAction, + { + onSuccess: (res) => { + toast.success('Key successfully rotated'); + router.refresh(); + if (res.data) { + setSecretKey(res.data); + } + }, + onError: () => { + toast.error('Failed to rotate key'); + } + } + ); + const handleRotateKey = async () => { + const res = await openDialog({ + title: 'Rotate key', + description: + 'Are you sure you want to rotate this key? It may break any services that are using it.', + confirmText: 'Rotate' + }); + if (res) { + rotateKey({ + secretKeyId: apiKey.id + }); + } + }; - // Delete key - const { execute: deleteKey, isExecuting: isDeleting } = useAction( - deleteSecretKeyAction, - { - onSuccess: () => { - toast.success("Key successfully deleted"); - router.refresh(); - }, - onError: () => { - toast.error("Failed to delete key"); - }, - } - ); - const handleDeleteKey = async () => { - const res = await openDialog({ - title: "Delete key", - description: - "Are you sure you want to delete this key? It may break any services that are using it. This action cannot be undone.", - confirmText: "Delete", - }); - if (res) { - deleteKey({ - secretKeyId: apiKey.id, - }); - } - }; - return ( -
-
-
-
{apiKey.name}
+ // Delete key + const { execute: deleteKey, isExecuting: isDeleting } = useAction( + deleteSecretKeyAction, + { + onSuccess: () => { + toast.success('Key successfully deleted'); + router.refresh(); + }, + onError: () => { + toast.error('Failed to delete key'); + } + } + ); + const handleDeleteKey = async () => { + const res = await openDialog({ + title: 'Delete key', + description: + 'Are you sure you want to delete this key? It may break any services that are using it. This action cannot be undone.', + confirmText: 'Delete' + }); + if (res) { + deleteKey({ + secretKeyId: apiKey.id + }); + } + }; + return ( +
+
+
+
{apiKey.name}
- {apiKey?.isPublic ? ( - - - - - - -

Click to copy

-
-
-
- ) : ( -

- {apiKey.prefix}...{apiKey.end} -

- )} -
-
- {/* Only secret keys are deletable */} - {!apiKey.isPublic && ( - - - - - - - Rotate key - - - Delete key - - - - )} -
-
- - setSecretKey(null)} - apiKey={secretKey} - /> -
- ); + {apiKey?.isPublic ? ( + + + + + + +

Click to copy

+
+
+
+ ) : ( +

+ {apiKey.prefix}...{apiKey.end} +

+ )} +
+
+ {/* Only secret keys are deletable */} + {!apiKey.isPublic && ( + + + + + + + Rotate key + + + Delete key + + + + )} +
+
+ + setSecretKey(null)} + open={!!secretKey} + /> +
+ ); } diff --git a/apps/web/features/api-keys/create-secret-key-modal-button.tsx b/apps/web/features/api-keys/create-secret-key-modal-button.tsx index be7aa8c98..edc17c011 100644 --- a/apps/web/features/api-keys/create-secret-key-modal-button.tsx +++ b/apps/web/features/api-keys/create-secret-key-modal-button.tsx @@ -1,37 +1,37 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui/button"; -import { CreateSecretKeyModal } from "./create-secret-key-modal"; -import { useState } from "react"; -import { SecretKeyRevealModal } from "./secret-key-reveal-modal"; -import { ApiKey } from "@/lib/core/api-keys/types"; +import { Button } from '@voidhash/ui/button'; +import { useState } from 'react'; +import type { ApiKey } from '@/lib/core/api-keys/types'; +import { CreateSecretKeyModal } from './create-secret-key-modal'; +import { SecretKeyRevealModal } from './secret-key-reveal-modal'; export function CreateSecretKeyModalButton({ - projectId, + projectId }: { - projectId: string; + projectId: string; }) { - const [open, setOpen] = useState(false); - const [secretKey, setSecretKey] = useState(null); - return ( - <> - setOpen(false)} - onSuccess={(apiKey) => { - setOpen(false); - setSecretKey(apiKey); - }} - trigger={ - - } - projectId={projectId} - /> - setSecretKey(null)} - apiKey={secretKey} - /> - - ); + const [open, setOpen] = useState(false); + const [secretKey, setSecretKey] = useState(null); + return ( + <> + setOpen(false)} + onSuccess={(apiKey) => { + setOpen(false); + setSecretKey(apiKey); + }} + open={open} + projectId={projectId} + trigger={ + + } + /> + setSecretKey(null)} + open={!!secretKey} + /> + + ); } diff --git a/apps/web/features/api-keys/create-secret-key-modal.tsx b/apps/web/features/api-keys/create-secret-key-modal.tsx index dac215aff..a19fcc396 100644 --- a/apps/web/features/api-keys/create-secret-key-modal.tsx +++ b/apps/web/features/api-keys/create-secret-key-modal.tsx @@ -1,127 +1,127 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui/button"; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@voidhash/ui/button'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@voidhash/ui/dialog'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; -import { useAction } from "next-safe-action/hooks"; -import { createSecretKeyAction } from "@/lib/nextjs/server-actions"; -import { ApiKey } from "@/lib/core/api-keys/types"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import type { ApiKey } from '@/lib/core/api-keys/types'; +import { createSecretKeyAction } from '@/lib/nextjs/server-actions'; const createSecretKeySchema = z.object({ - name: z - .string() - .min(3, "Name must be at least 3 characters long") - .max(32, "Name must be less than 32 characters"), + name: z + .string() + .min(3, 'Name must be at least 3 characters long') + .max(32, 'Name must be less than 32 characters') }); type CreateSecretKeyForm = z.infer; interface CreateSecretKeyModalProps { - open: boolean; - onClose: () => void; - trigger: React.ReactNode; - projectId: string; - onSuccess?: (apiKey: ApiKey) => void; + open: boolean; + onClose: () => void; + trigger: React.ReactNode; + projectId: string; + onSuccess?: (apiKey: ApiKey) => void; } export function CreateSecretKeyModal({ - open, - onClose, - trigger, - projectId, - onSuccess, + open, + onClose, + trigger, + projectId, + onSuccess }: CreateSecretKeyModalProps) { - const form = useForm({ - resolver: zodResolver(createSecretKeySchema), - defaultValues: { - name: "", - }, - }); + const form = useForm({ + resolver: zodResolver(createSecretKeySchema), + defaultValues: { + name: '' + } + }); - const { execute, isPending } = useAction(createSecretKeyAction, { - onSuccess: (res) => { - if (res.data) { - toast.success("Secret key created successfully"); - onSuccess?.(res.data); - handleOpenChange(false); - } - }, - onError: (error) => { - toast.error(error.error.serverError || "Failed to create secret key"); - }, - }); + const { execute, isPending } = useAction(createSecretKeyAction, { + onSuccess: (res) => { + if (res.data) { + toast.success('Secret key created successfully'); + onSuccess?.(res.data); + handleOpenChange(false); + } + }, + onError: (error) => { + toast.error(error.error.serverError || 'Failed to create secret key'); + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - form.reset(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + form.reset(); + } + }; - const onSubmit = (data: CreateSecretKeyForm) => { - execute({ ...data, projectId }); - }; + const onSubmit = (data: CreateSecretKeyForm) => { + execute({ ...data, projectId }); + }; - return ( - - {trigger} - - - Create New Secret Key - - Create a new secret key to authenticate your API requests. - - -
- - ( - - Key Name - - - - - - )} - /> - - - - - -
-
- ); + return ( + + {trigger} + + + Create New Secret Key + + Create a new secret key to authenticate your API requests. + + +
+ + ( + + Key Name + + + + + + )} + /> + + + + + +
+
+ ); } diff --git a/apps/web/features/api-keys/project-api-keys-page-skeleton.tsx b/apps/web/features/api-keys/project-api-keys-page-skeleton.tsx index b82146870..7fda9131f 100644 --- a/apps/web/features/api-keys/project-api-keys-page-skeleton.tsx +++ b/apps/web/features/api-keys/project-api-keys-page-skeleton.tsx @@ -1,23 +1,24 @@ -import { Card } from "@voidhash/ui"; -import { ApiKeyRecordSkeleton } from "./api-key-record-skeleton"; +import { Card } from '@voidhash/ui'; +import { ApiKeyRecordSkeleton } from './api-key-record-skeleton'; export function ProjectApiKeysPageSkeleton() { - return ( -
-
-
-

API Keys

-

Manage your API keys

-
-
+ return ( +
+
+
+

API Keys

+

Manage your API keys

+
+
-
- - {Array.from({ length: 3 }).map((_, index) => ( - - ))} - -
-
- ); +
+ + {Array.from({ length: 3 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: just a skeleton + + ))} + +
+
+ ); } diff --git a/apps/web/features/api-keys/project-api-keys-page.tsx b/apps/web/features/api-keys/project-api-keys-page.tsx index 5bc7d7b88..063bcf655 100644 --- a/apps/web/features/api-keys/project-api-keys-page.tsx +++ b/apps/web/features/api-keys/project-api-keys-page.tsx @@ -1,87 +1,87 @@ -import { Card } from "@voidhash/ui"; -import { ApiKeyRecord } from "./api-key-record"; -import { CreateSecretKeyModalButton } from "./create-secret-key-modal-button"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { ApiKeyService } from "@/lib/services/api-key.service"; -import { Effect } from "effect"; -import { ProjectService } from "@/lib/services/project.service"; -import { NotFoundError } from "@/lib/effect/errors"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Card } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { ApiKeyService } from '@/lib/services/api-key.service'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { ApiKeyRecord } from './api-key-record'; +import { CreateSecretKeyModalButton } from './create-secret-key-modal-button'; export async function ProjectApiKeysPage({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string; - projectSlug: string; + organizationSlug: string; + projectSlug: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const apiKeyService = yield* ApiKeyService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - organizationSlug, - projectSlug, - }); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - const apiKeys = yield* apiKeyService.getApiKeys(project.id); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const apiKeyService = yield* ApiKeyService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + organizationSlug, + projectSlug + }); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + const apiKeys = yield* apiKeyService.getApiKeys(project.id); - return { project, apiKeys }; - }) - ); - }) - ); - }) - ); + return { project, apiKeys }; + }) + ); + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { project, apiKeys } = data.value; + const { project, apiKeys } = data.value; - return ( -
-
-
-

API Keys

-

Manage your API keys

-
- -
+ return ( +
+
+
+

API Keys

+

Manage your API keys

+
+ +
-
- - {apiKeys.map((apiKey) => ( - - ))} - -
-
- ); +
+ + {apiKeys.map((apiKey) => ( + + ))} + +
+
+ ); } diff --git a/apps/web/features/api-keys/secret-key-reveal-modal.tsx b/apps/web/features/api-keys/secret-key-reveal-modal.tsx index 3ef3841df..2d4ea6f6f 100644 --- a/apps/web/features/api-keys/secret-key-reveal-modal.tsx +++ b/apps/web/features/api-keys/secret-key-reveal-modal.tsx @@ -1,64 +1,66 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui/button"; +import { Button } from '@voidhash/ui/button'; +import { CopyText } from '@voidhash/ui/copy-text'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@voidhash/ui/dialog"; -import { CopyText } from "@voidhash/ui/copy-text"; -import { ApiKey } from "@/lib/core/api-keys/types"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@voidhash/ui/dialog'; +import type { ApiKey } from '@/lib/core/api-keys/types'; interface SecretKeyRevealModalProps { - open: boolean; - onClose: () => void; - apiKey: ApiKey | null; + open: boolean; + onClose: () => void; + apiKey: ApiKey | null; } export function SecretKeyRevealModal({ - open, - onClose, + open, + onClose, - apiKey, + apiKey }: SecretKeyRevealModalProps) { - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + } + }; - return ( - - - - Your New Secret Key - - Make sure to copy your new secret key now - you won't be able - to see it again! - - -
-
- - -
- - - -
-
-
- ); + return ( + + + + Your New Secret Key + + Make sure to copy your new secret key now - you won't be able + to see it again! + + +
+
+ + +
+ + + +
+
+
+ ); } diff --git a/apps/web/features/auth/hooks/useMe.tsx b/apps/web/features/auth/hooks/useMe.tsx index 4dd3caab7..6200f2d70 100644 --- a/apps/web/features/auth/hooks/useMe.tsx +++ b/apps/web/features/auth/hooks/useMe.tsx @@ -1,9 +1,9 @@ -"use client"; +'use client'; -import { useQuery } from "@tanstack/react-query"; -import { useTRPC } from "../../trpc/react"; +import { useQuery } from '@tanstack/react-query'; +import { useTRPC } from '../../trpc/react'; export function useMe() { - const trpc = useTRPC(); - return useQuery(trpc.auth.me.queryOptions()); + const trpc = useTRPC(); + return useQuery(trpc.auth.me.queryOptions()); } diff --git a/apps/web/features/customers/create-customer-button.tsx b/apps/web/features/customers/create-customer-button.tsx index 868dc2645..142a22c8f 100644 --- a/apps/web/features/customers/create-customer-button.tsx +++ b/apps/web/features/customers/create-customer-button.tsx @@ -1,17 +1,17 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui"; -import { CreateCustomerModal } from "./create-customer-modal"; -import { useState } from "react"; +import { Button } from '@voidhash/ui'; +import { useState } from 'react'; +import { CreateCustomerModal } from './create-customer-modal'; export function CreateCustomerButton({ projectId }: { projectId: string }) { - const [open, setOpen] = useState(false); - return ( - setOpen(false)} - trigger={} - projectId={projectId} - /> - ); + const [open, setOpen] = useState(false); + return ( + setOpen(false)} + open={open} + projectId={projectId} + trigger={} + /> + ); } diff --git a/apps/web/features/customers/create-customer-modal.tsx b/apps/web/features/customers/create-customer-modal.tsx index 2d07579ef..598b7de16 100644 --- a/apps/web/features/customers/create-customer-modal.tsx +++ b/apps/web/features/customers/create-customer-modal.tsx @@ -1,173 +1,173 @@ -"use client"; -import { Button } from "@voidhash/ui/button"; +'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { InfoTooltip } from '@voidhash/ui'; +import { Button } from '@voidhash/ui/button'; import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@voidhash/ui/dialog'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; - -import { toast } from "sonner"; -import { useAction } from "next-safe-action/hooks"; -import { createCustomerAction } from "@/lib/nextjs/server-actions"; - -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; -import { InfoTooltip } from "@voidhash/ui"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { createCustomerAction } from '@/lib/nextjs/server-actions'; // Extract the relevant parts from createCustomerInputSchema for the form const createCustomerFormSchema = z.object({ - appUserId: z.string().min(1), - name: z.string().optional(), - email: z.string().email().optional(), + appUserId: z.string().min(1), + name: z.string().optional(), + email: z.string().email().optional() }); type CreateCustomerForm = z.infer; interface CreateCustomerModalProps { - trigger: React.ReactNode; - open: boolean; - onClose: () => void; - projectId: string; + trigger: React.ReactNode; + open: boolean; + onClose: () => void; + projectId: string; } export function CreateCustomerModal({ - open, - onClose, - trigger, - projectId, + open, + onClose, + trigger, + projectId }: CreateCustomerModalProps) { - const router = useRouter(); - const form = useForm({ - resolver: zodResolver(createCustomerFormSchema), - defaultValues: { - appUserId: "", - name: "", - email: "", - }, - }); + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(createCustomerFormSchema), + defaultValues: { + appUserId: '', + name: '', + email: '' + } + }); - const { execute, isPending } = useAction(createCustomerAction, { - onSuccess: async () => { - router.refresh(); - toast.success("Customer created successfully!"); - form.reset(); - onClose?.(); - }, - onError: (error) => { - // Use the serverError field if available, otherwise fallback - const errorMessage = - error.error.serverError || - error.error.validationErrors?._errors?.join(", ") || // Combine top-level validation errors - "Failed to create customer"; - toast.error(errorMessage); - }, - }); + const { execute, isPending } = useAction(createCustomerAction, { + onSuccess: () => { + router.refresh(); + toast.success('Customer created successfully!'); + form.reset(); + onClose?.(); + }, + onError: (error) => { + // Use the serverError field if available, otherwise fallback + const errorMessage = + error.error.serverError || + error.error.validationErrors?._errors?.join(', ') || // Combine top-level validation errors + 'Failed to create customer'; + toast.error(errorMessage); + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - form.reset(); // Reset form when closing - onClose?.(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + form.reset(); // Reset form when closing + onClose?.(); + } + }; - const onSubmit = (data: CreateCustomerForm) => { - execute({ - ...data, - name: data.name ?? null, - email: data.email ?? null, - projectId, // Add the projectId required by the action - }); - }; + const onSubmit = (data: CreateCustomerForm) => { + execute({ + ...data, + name: data.name ?? null, + email: data.email ?? null, + projectId // Add the projectId required by the action + }); + }; - useEffect(() => { - form.reset(); - }, [open]); + useEffect(() => { + if (!open) { + form.reset(); + } + }, [form, open]); - return ( - - {trigger} - - - Create Customer - -
- - ( - - - App User ID{" "} - - - - - - - - )} - /> - ( - - Name - - - - - - )} - /> - ( - - Email - - - - - - )} - /> - - - - - -
-
- ); + return ( + + {trigger} + + + Create Customer + +
+ + ( + + + App User ID{' '} + + + + + + + + )} + /> + ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + + + + + +
+
+ ); } diff --git a/apps/web/features/customers/customers-detail-page.tsx b/apps/web/features/customers/customers-detail-page.tsx index 2368a297c..c7d826bae 100644 --- a/apps/web/features/customers/customers-detail-page.tsx +++ b/apps/web/features/customers/customers-detail-page.tsx @@ -1,150 +1,150 @@ -import { Page } from "../shell"; -import { Card, CardContent, CardHeader, CardTitle } from "@voidhash/ui"; -import { format } from "date-fns"; -import { Clock4Icon } from "lucide-react"; -import { VoidhashErrorCard } from "../shell/components/voidhash-error-card"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { CustomerService } from "@/lib/services/customer.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; -import { NotFoundError } from "@/lib/effect/errors"; +import { Card, CardContent, CardHeader, CardTitle } from '@voidhash/ui'; +import { format } from 'date-fns'; +import { Effect } from 'effect'; +import { Clock4Icon } from 'lucide-react'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { CustomerService } from '@/lib/services/customer.service'; +import { Page } from '../shell'; +import { VoidhashErrorCard } from '../shell/components/voidhash-error-card'; export async function CustomerDetailPage({ - customerId, - organizationSlug, - projectSlug, + customerId, + organizationSlug, + projectSlug }: { - customerId: string; - organizationSlug: string; - projectSlug: string; + customerId: string; + organizationSlug: string; + projectSlug: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const customer = yield* customerService.getCustomerById(customerId); - const customerPurchases = - yield* customerService.getCustomerPurchases(customerId); - const customerUnlockedPerks = - yield* customerService.getCustomersUnlockedPerks(customerId); - return { customer, customerPurchases, customerUnlockedPerks }; - }).pipe( - Effect.catchTags({ - CustomerNotFoundError: (error) => - Effect.fail(new NotFoundError({ message: error.message })), - }), - ), - ); - }), - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const customer = yield* customerService.getCustomerById(customerId); + const customerPurchases = + yield* customerService.getCustomerPurchases(customerId); + const customerUnlockedPerks = + yield* customerService.getCustomersUnlockedPerks(customerId); + return { customer, customerPurchases, customerUnlockedPerks }; + }).pipe( + Effect.catchTags({ + CustomerNotFoundError: (error) => + Effect.fail(new NotFoundError({ message: error.message })) + }) + ) + ); + }) + ); - if (data.isErr()) { - return ; - } + if (data.isErr()) { + return ; + } - const { customer, customerPurchases, customerUnlockedPerks } = data.value; + const { customer, customerPurchases, customerUnlockedPerks } = data.value; - const title = - customer.name ?? customer.email ?? customer.appUserId ?? customer.id; + const title = + customer.name ?? customer.email ?? customer.appUserId ?? customer.id; - return ( - -
-
-
-

{title}

-
- {customer.email && ( -

{customer.email}

- )} -
-
-
-
-
-
- - - - Purchases - - - - {/* Emtpy State */} - {customerPurchases.length === 0 && ( -
-
- Customer has not made any purchases. -
-
- )} + return ( + +
+
+
+

{title}

+
+ {customer.email && ( +

{customer.email}

+ )} +
+
+
+
+
+
+ + + + Purchases + + + + {/* Emtpy State */} + {customerPurchases.length === 0 && ( +
+
+ Customer has not made any purchases. +
+
+ )} - {customerPurchases.map((purchase) => ( -
{purchase.id}
- ))} -
-
+ {customerPurchases.map((purchase) => ( +
{purchase.id}
+ ))} + + -
- - - - Unlocked Perks - - - - {/* Emtpy State */} - {customerUnlockedPerks.length === 0 && ( -
-
- Customer has no unlocked perks. -
-
- )} +
+ + + + Unlocked Perks + + + + {/* Emtpy State */} + {customerUnlockedPerks.length === 0 && ( +
+
+ Customer has no unlocked perks. +
+
+ )} - {customerUnlockedPerks.map((unlockedPerk) => ( -
{unlockedPerk.id}
- ))} -
-
-
-
-
-
-

- Details -

-
- {customer.createdAt && ( -
-

Created at

-
- -

- {format(customer.createdAt, "MMM d, yyyy")} -

-
-
- )} -
-
-
-
- - ); + {customerUnlockedPerks.map((unlockedPerk) => ( +
{unlockedPerk.id}
+ ))} + + +
+
+
+
+

+ Details +

+
+ {customer.createdAt && ( +
+

Created at

+
+ +

+ {format(customer.createdAt, 'MMM d, yyyy')} +

+
+
+ )} +
+
+
+
+
+ ); } diff --git a/apps/web/features/customers/customers-page.tsx b/apps/web/features/customers/customers-page.tsx index 7ae2172d8..92cbd4cee 100644 --- a/apps/web/features/customers/customers-page.tsx +++ b/apps/web/features/customers/customers-page.tsx @@ -1,80 +1,80 @@ -import { Page } from "@/features/shell"; -import { CustomersTable } from "./customers-table"; -import { CreateCustomerButton } from "./create-customer-button"; +import { CustomerType } from '@voidhash/db'; import { - UnderlineTabs, - UnderlineTabsContent, - UnderlineTabsList, - UnderlineTabsTrigger, -} from "@voidhash/ui"; -import { VoidhashErrorCard } from "../shell/components/voidhash-error-card"; -import { Effect } from "effect"; -import { ProjectService } from "@/lib/services/project.service"; -import { NotFoundError } from "@/lib/effect/errors"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { CustomerType } from "@voidhash/db"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + UnderlineTabs, + UnderlineTabsContent, + UnderlineTabsList, + UnderlineTabsTrigger +} from '@voidhash/ui'; +import { Effect } from 'effect'; +import { Page } from '@/features/shell'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { VoidhashErrorCard } from '../shell/components/voidhash-error-card'; +import { CreateCustomerButton } from './create-customer-button'; +import { CustomersTable } from './customers-table'; export async function CustomersPage({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string; - projectSlug; + organizationSlug: string; + projectSlug; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - return { project }; - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + return { project }; + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { project } = data.value; + const { project } = data.value; - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} + return ( + + {/* Key is used to reload the default form data when the organization slug changes */} -
-

Customers

- -
- {/*

+

+

Customers

+ +
+ {/*

List of products available to purchase.

*/} -
- - -
- - Identified - - - Anonymous {/* Number of unidentified customers */} - {/* {!!10 && ( +
+ + +
+ + Identified + + + Anonymous {/* Number of unidentified customers */} + {/* {!!10 && ( )} */} - -
-
- -
- -
-
- -
- -
-
-
-
- - ); +
+
+
+ +
+ +
+
+ +
+ +
+
+
+
+
+ ); } diff --git a/apps/web/features/customers/customers-table/columns.tsx b/apps/web/features/customers/customers-table/columns.tsx index 392dc69a1..bdce897c8 100644 --- a/apps/web/features/customers/customers-table/columns.tsx +++ b/apps/web/features/customers/customers-table/columns.tsx @@ -1,33 +1,33 @@ -"use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { format } from "date-fns"; -import { type Customer } from "@voidhash/db"; +'use client'; +import type { ColumnDef } from '@tanstack/react-table'; +import type { Customer } from '@voidhash/db'; +import { format } from 'date-fns'; export const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: "Name", - }, - { - accessorKey: "email", - header: "Email", - cell: ({ row }) => { - return ( - {row.original.email} - ); - }, - }, - { - accessorKey: "createdAt", - header: "Created", - cell: ({ row }) => { - return ( - - {row.original.createdAt - ? format(new Date(row.original.createdAt), "MMM d, yyyy") - : "N/A"} - - ); - }, - }, + { + accessorKey: 'name', + header: 'Name' + }, + { + accessorKey: 'email', + header: 'Email', + cell: ({ row }) => { + return ( + {row.original.email} + ); + } + }, + { + accessorKey: 'createdAt', + header: 'Created', + cell: ({ row }) => { + return ( + + {row.original.createdAt + ? format(new Date(row.original.createdAt), 'MMM d, yyyy') + : 'N/A'} + + ); + } + } ]; diff --git a/apps/web/features/customers/customers-table/data-table.tsx b/apps/web/features/customers/customers-table/data-table.tsx index b8ba3a039..0dfed2e42 100644 --- a/apps/web/features/customers/customers-table/data-table.tsx +++ b/apps/web/features/customers/customers-table/data-table.tsx @@ -1,93 +1,93 @@ -"use client"; +'use client'; import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable +} from '@tanstack/react-table'; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@voidhash/ui"; -import { useRouter } from "next/navigation"; + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@voidhash/ui'; +import { useRouter } from 'next/navigation'; interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - organizationSlug: string; - projectSlug: string; + columns: ColumnDef[]; + data: TData[]; + organizationSlug: string; + projectSlug: string; } export function DataTable({ - columns, - data, - organizationSlug, - projectSlug, + columns, + data, + organizationSlug, + projectSlug }: DataTableProps) { - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - }); + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel() + }); - const router = useRouter(); + const router = useRouter(); - return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - { - router.push( - `/${organizationSlug}/${projectSlug}/customers/${row.original.id}` - ); - }} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No customers in your project yet. - - - )} - -
-
- ); + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + { + router.push( + `/${organizationSlug}/${projectSlug}/customers/${row.original.id}` + ); + }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No customers in your project yet. + + + )} + +
+
+ ); } diff --git a/apps/web/features/customers/customers-table/index.tsx b/apps/web/features/customers/customers-table/index.tsx index a9bcd9264..c85fccb59 100644 --- a/apps/web/features/customers/customers-table/index.tsx +++ b/apps/web/features/customers/customers-table/index.tsx @@ -1,62 +1,62 @@ -import { columns } from "./columns"; -import { DataTable } from "./data-table"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; -import { CustomerService } from "@/lib/services/customer.service"; +import type { CustomerTypeValue } from '@voidhash/db'; +import { Effect } from 'effect'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { CustomerService } from '@/lib/services/customer.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; -import { CustomerTypeValue } from "@voidhash/db"; -import { Effect } from "effect"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { columns } from './columns'; +import { DataTable } from './data-table'; export async function CustomersTable({ - projectId, - type, - organizationSlug, - projectSlug, + projectId, + type, + organizationSlug, + projectSlug }: { - projectId: string; - type?: CustomerTypeValue; - organizationSlug: string; - projectSlug: string; + projectId: string; + type?: CustomerTypeValue; + organizationSlug: string; + projectSlug: string; }) { - const customersResult = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const customerService = yield* CustomerService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - projectId, - }); - return yield* Environment.provide(environment)( - customerService.getCustomers({ - projectId, - type: type, - }) - ); - }) - ); - }) - ); + const customersResult = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const customerService = yield* CustomerService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + projectId + }); + return yield* Environment.provide(environment)( + customerService.getCustomers({ + projectId, + type + }) + ); + }) + ); + }) + ); - if (customersResult.isErr()) { - return ; - } + if (customersResult.isErr()) { + return ; + } - const customers = customersResult.value; + const customers = customersResult.value; - return ( - - ); + return ( + + ); } diff --git a/apps/web/features/developers/developers-page.tsx b/apps/web/features/developers/developers-page.tsx index 544b6aeff..4c6679ce4 100644 --- a/apps/web/features/developers/developers-page.tsx +++ b/apps/web/features/developers/developers-page.tsx @@ -1,54 +1,54 @@ -import { Page } from "@/features/shell"; -import { VoidhashErrorCard } from "../shell/components/voidhash-error-card"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { Effect } from "effect"; -import { NotFoundError } from "@/lib/effect/errors"; -import { ProjectService } from "@/lib/services/project.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Effect } from 'effect'; +import { Page } from '@/features/shell'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { VoidhashErrorCard } from '../shell/components/voidhash-error-card'; export async function DevelopersPage({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string; - projectSlug; + organizationSlug: string; + projectSlug; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - return { project }; - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + return { project }; + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const {} = data.value; + // const {} = data.value; - return ( - -
-

Developers

-
-
- ); + return ( + +
+

Developers

+
+
+ ); } diff --git a/apps/web/features/developers/developers-tab-bar.tsx b/apps/web/features/developers/developers-tab-bar.tsx index 634b8d945..97a2c1b07 100644 --- a/apps/web/features/developers/developers-tab-bar.tsx +++ b/apps/web/features/developers/developers-tab-bar.tsx @@ -1,32 +1,34 @@ -"use client"; +'use client'; import { - UnderlineTabs, - UnderlineTabsList, - UnderlineTabsTrigger, -} from "@voidhash/ui"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; + UnderlineTabs, + UnderlineTabsList, + UnderlineTabsTrigger +} from '@voidhash/ui'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; export function DevelopersTabBar({ - tabs, -}: { tabs: { label: string; path: string }[] }) { - const pathname = usePathname(); - return ( - - -
- {tabs.map((tab) => ( - - {tab.label} - - ))} -
-
-
- ); + tabs +}: { + tabs: { label: string; path: string }[]; +}) { + const pathname = usePathname(); + return ( + + +
+ {tabs.map((tab) => ( + + {tab.label} + + ))} +
+
+
+ ); } diff --git a/apps/web/features/development-paywall/development-paywall-page.tsx b/apps/web/features/development-paywall/development-paywall-page.tsx index aa8a44530..53e221735 100644 --- a/apps/web/features/development-paywall/development-paywall-page.tsx +++ b/apps/web/features/development-paywall/development-paywall-page.tsx @@ -1,3 +1,3 @@ export default function DevelopmentPaywallPage() { - return
DevelopmentPaywallPage
; + return
DevelopmentPaywallPage
; } diff --git a/apps/web/features/lib/types.ts b/apps/web/features/lib/types.ts index 1754bb7a1..c21e59904 100644 --- a/apps/web/features/lib/types.ts +++ b/apps/web/features/lib/types.ts @@ -1,3 +1,2 @@ -export type QueryData Promise> = Awaited< - ReturnType ->; +export type QueryData Promise> = + Awaited>; diff --git a/apps/web/features/organizations/create-organization-modal.tsx b/apps/web/features/organizations/create-organization-modal.tsx index 4b772bd5a..af84bd673 100644 --- a/apps/web/features/organizations/create-organization-modal.tsx +++ b/apps/web/features/organizations/create-organization-modal.tsx @@ -1,131 +1,131 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui/button"; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQueryClient } from '@tanstack/react-query'; +import { Button } from '@voidhash/ui/button'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@voidhash/ui/dialog'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; -import { useRouter } from "next/navigation"; -import { useAction } from "next-safe-action/hooks"; -import { createOrganizationAction } from "@/lib/nextjs/server-actions"; -import { useQueryClient } from "@tanstack/react-query"; -import { useTRPC } from "../trpc/react"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { createOrganizationAction } from '@/lib/nextjs/server-actions'; +import { useTRPC } from '../trpc/react'; const createOrganizationSchema = z.object({ - name: z - .string() - .min(1, "Organization name is required") - .max(32, "Organization name must be less than 32 characters"), + name: z + .string() + .min(1, 'Organization name is required') + .max(32, 'Organization name must be less than 32 characters') }); type CreateOrganizationForm = z.infer; interface CreateOrganizationModalProps { - open: boolean; - onClose: () => void; - trigger: React.ReactNode; + open: boolean; + onClose: () => void; + trigger: React.ReactNode; } export function CreateOrganizationModal({ - open, - onClose, - trigger, + open, + onClose, + trigger }: CreateOrganizationModalProps) { - const router = useRouter(); - const queryClient = useQueryClient(); - const trpc = useTRPC(); + const router = useRouter(); + const queryClient = useQueryClient(); + const trpc = useTRPC(); - const form = useForm({ - resolver: zodResolver(createOrganizationSchema), - defaultValues: { - name: "", - }, - }); + const form = useForm({ + resolver: zodResolver(createOrganizationSchema), + defaultValues: { + name: '' + } + }); - const { execute, isPending } = useAction(createOrganizationAction, { - onSuccess: async (res) => { - if (res?.data?.id) { - onClose?.(); - queryClient.invalidateQueries({ - queryKey: trpc.pathKey(), - }); - // Navigate to the new organization - router.push(`/${res?.data?.slug}`); - } - }, - onError: (error) => { - toast.error(error.error.serverError); - }, - }); + const { execute, isPending } = useAction(createOrganizationAction, { + onSuccess: (res) => { + if (res?.data?.id) { + onClose?.(); + queryClient.invalidateQueries({ + queryKey: trpc.pathKey() + }); + // Navigate to the new organization + router.push(`/${res?.data?.slug}`); + } + }, + onError: (error) => { + toast.error(error.error.serverError); + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + } + }; - const onSubmit = (data: CreateOrganizationForm) => { - execute(data); - }; + const onSubmit = (data: CreateOrganizationForm) => { + execute(data); + }; - return ( - - {trigger} - - - Create New Team - - Create a new team to collaborate with your colleagues. - - -
- - ( - - Team Name - - - - - - )} - /> - - - - - -
-
- ); + return ( + + {trigger} + + + Create New Team + + Create a new team to collaborate with your colleagues. + + +
+ + ( + + Team Name + + + + + + )} + /> + + + + + +
+
+ ); } diff --git a/apps/web/features/organizations/delete-organization-modal.tsx b/apps/web/features/organizations/delete-organization-modal.tsx index 10cc49929..94bd841a6 100644 --- a/apps/web/features/organizations/delete-organization-modal.tsx +++ b/apps/web/features/organizations/delete-organization-modal.tsx @@ -1,119 +1,119 @@ -import { Button } from "@voidhash/ui/button"; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@voidhash/ui/button'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@voidhash/ui/dialog'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; interface DeleteOrganizationModalProps { - open: boolean; - onClose: () => void; - onDelete: () => void; - trigger: React.ReactNode; - organizationSlug: string; + open: boolean; + onClose: () => void; + onDelete: () => void; + trigger: React.ReactNode; + organizationSlug: string; } type DeleteOrganizationForm = { - confirmation: string; + confirmation: string; }; export function DeleteOrganizationModal({ - open, - onClose, - onDelete, - trigger, - organizationSlug, + open, + onClose, + onDelete, + trigger, + organizationSlug }: DeleteOrganizationModalProps) { - const deleteOrganizationSchema = z.object({ - confirmation: z - .string() - .refine((value) => value === `${organizationSlug}`, { - message: - "Please enter the text exactly as it is shown to confirm deletion", - }), - }); + const deleteOrganizationSchema = z.object({ + confirmation: z + .string() + .refine((value) => value === `${organizationSlug}`, { + message: + 'Please enter the text exactly as it is shown to confirm deletion' + }) + }); - const form = useForm({ - resolver: zodResolver(deleteOrganizationSchema), - defaultValues: { - confirmation: "", - }, - }); + const form = useForm({ + resolver: zodResolver(deleteOrganizationSchema), + defaultValues: { + confirmation: '' + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + } + }; - const onSubmit = () => { - onClose(); - onDelete(); - }; + const onSubmit = () => { + onClose(); + onDelete(); + }; - return ( - - {trigger} - - - Delete Team - - This action cannot be undone. This will permanently delete the - organization and all associated data. - - -
- - ( - - - Please type{" "} - - {organizationSlug} - {" "} - to confirm - - - - - - - )} - /> + return ( + + {trigger} + + + Delete Team + + This action cannot be undone. This will permanently delete the + organization and all associated data. + + + + + ( + + + Please type{' '} + + {organizationSlug} + {' '} + to confirm + + + + + + + )} + /> - - - - - - - - - ); + + + + + + +
+
+ ); } diff --git a/apps/web/features/organizations/projects/empty-state.tsx b/apps/web/features/organizations/projects/empty-state.tsx index 787b57915..c052a5369 100644 --- a/apps/web/features/organizations/projects/empty-state.tsx +++ b/apps/web/features/organizations/projects/empty-state.tsx @@ -1,38 +1,41 @@ -"use client"; -import { CreateProjectModal } from "@/features/projects/create-project-modal"; +'use client'; import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Button, -} from "@voidhash/ui"; -import { useState } from "react"; + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@voidhash/ui'; +import { useState } from 'react'; +import { CreateProjectModal } from '@/features/projects/create-project-modal'; export function EmptyState({ - organizationId, - organizationSlug, -}: { organizationId: string; organizationSlug: string }) { - const [open, setOpen] = useState(false); + organizationId, + organizationSlug +}: { + organizationId: string; + organizationSlug: string; +}) { + const [open, setOpen] = useState(false); - return ( - - - No projects yet - Create a project to get started. - - - setOpen(false)} - trigger={ - - } - organizationId={organizationId} - organizationSlug={organizationSlug} - /> - - - ); + return ( + + + No projects yet + Create a project to get started. + + + setOpen(false)} + open={open} + organizationId={organizationId} + organizationSlug={organizationSlug} + trigger={ + + } + /> + + + ); } diff --git a/apps/web/features/organizations/projects/projects-list.tsx b/apps/web/features/organizations/projects/projects-list.tsx index 7f9d22aaf..812a9c37e 100644 --- a/apps/web/features/organizations/projects/projects-list.tsx +++ b/apps/web/features/organizations/projects/projects-list.tsx @@ -1,122 +1,122 @@ import { - Card, - GradientAvatar, - DropdownMenu, - DropdownMenuTrigger, - Button, - DropdownMenuContent, - DropdownMenuItem, -} from "@voidhash/ui"; -import { EllipsisVerticalIcon } from "lucide-react"; -import Link from "next/link"; -import { EmptyState } from "./empty-state"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { Effect } from "effect"; -import { NotFoundError } from "@/lib/effect/errors"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { OrganizationService } from "@/lib/services/organization.service"; -import { ProjectService } from "@/lib/services/project.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + GradientAvatar +} from '@voidhash/ui'; +import { Effect } from 'effect'; +import { EllipsisVerticalIcon } from 'lucide-react'; +import Link from 'next/link'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { OrganizationService } from '@/lib/services/organization.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { EmptyState } from './empty-state'; export async function ProjectsList({ - organizationSlug, + organizationSlug }: { - organizationSlug: string; + organizationSlug: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const projectService = yield* ProjectService; - const [activeOrganization, projects] = yield* Effect.all( - [ - organizationService.getOrganizationBySlug(organizationSlug).pipe( - Effect.catchTags({ - OrganizationNotFound: () => - Effect.fail( - new NotFoundError({ - message: "Organization not found", - }), - ), - }), - ), - projectService.getProjectsByOrganizationSlug(organizationSlug), - ], - { - concurrency: "unbounded", - }, - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const projectService = yield* ProjectService; + const [activeOrganization, projects] = yield* Effect.all( + [ + organizationService.getOrganizationBySlug(organizationSlug).pipe( + Effect.catchTags({ + OrganizationNotFound: () => + Effect.fail( + new NotFoundError({ + message: 'Organization not found' + }) + ) + }) + ), + projectService.getProjectsByOrganizationSlug(organizationSlug) + ], + { + concurrency: 'unbounded' + } + ); - return { activeOrganization, organizationProjects: projects }; - }), - ); - }), - ); + return { activeOrganization, organizationProjects: projects }; + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { activeOrganization, organizationProjects } = data.value; + const { activeOrganization, organizationProjects } = data.value; - if (organizationProjects?.length === 0) { - return ( - - ); - } + if (organizationProjects?.length === 0) { + return ( + + ); + } - return ( - - {organizationProjects?.map((project) => ( -
- -
-
- -
-

{project.name}

-

- No URL specified -

-
-
- - - - - - - - Settings - - - - -
-
- ))} -
- ); + return ( + + {organizationProjects?.map((project) => ( +
+ +
+
+ +
+

{project.name}

+

+ No URL specified +

+
+
+ + + + + + + + Settings + + + + +
+
+ ))} +
+ ); } diff --git a/apps/web/features/organizations/projects/projects-page.tsx b/apps/web/features/organizations/projects/projects-page.tsx index 87dcb3361..c34563e4d 100644 --- a/apps/web/features/organizations/projects/projects-page.tsx +++ b/apps/web/features/organizations/projects/projects-page.tsx @@ -1,30 +1,30 @@ -import { Page } from "@/features/shell"; -import { ProjectsList } from "./projects-list"; -import { Suspense } from "react"; -import { ProjectsSkeleton } from "./projects-skeleton"; +import { Suspense } from 'react'; +import { Page } from '@/features/shell'; +import { ProjectsList } from './projects-list'; +import { ProjectsSkeleton } from './projects-skeleton'; -export async function ProjectsPage({ - params, +export function ProjectsPage({ + params }: { - params: { - organizationSlug: string; - }; + params: { + organizationSlug: string; + }; }) { - const { organizationSlug } = params; + const { organizationSlug } = params; - return ( - -
-

Projects

-

- All projects of organization {organizationSlug} -

-
- }> - - -
-
-
- ); + return ( + +
+

Projects

+

+ All projects of organization {organizationSlug} +

+
+ }> + + +
+
+
+ ); } diff --git a/apps/web/features/organizations/projects/projects-skeleton.tsx b/apps/web/features/organizations/projects/projects-skeleton.tsx index cdd6f7ef2..7b33847ea 100644 --- a/apps/web/features/organizations/projects/projects-skeleton.tsx +++ b/apps/web/features/organizations/projects/projects-skeleton.tsx @@ -1,23 +1,24 @@ -import { Card, Skeleton } from "@voidhash/ui"; +import { Card, Skeleton } from '@voidhash/ui'; export function ProjectsSkeleton() { - return ( - - {Array.from({ length: 3 }).map((_, index) => ( -
-
-
- -
- -
-
-
-
- ))} -
- ); + return ( + + {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+ +
+ +
+
+
+
+ ))} +
+ ); } diff --git a/apps/web/features/organizations/settings/data-table.tsx b/apps/web/features/organizations/settings/data-table.tsx index 7d5b6f9cb..be94132a3 100644 --- a/apps/web/features/organizations/settings/data-table.tsx +++ b/apps/web/features/organizations/settings/data-table.tsx @@ -1,136 +1,134 @@ -"use client"; +'use client'; import { - type ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; - -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@voidhash/ui"; + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable +} from '@tanstack/react-table'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@voidhash/ui"; -import { EllipsisIcon } from "lucide-react"; -import { Button } from "@voidhash/ui"; + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@voidhash/ui'; +import { EllipsisIcon } from 'lucide-react'; interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - emptyMessage?: string; - actions: { - onClick: (data: TData) => void; - label: string; - icon: React.ReactNode; - }[]; + columns: ColumnDef[]; + data: TData[]; + emptyMessage?: string; + actions: { + onClick: (data: TData) => void; + label: string; + icon: React.ReactNode; + }[]; } export function DataTable({ - columns, - data, - emptyMessage = "No data", - actions = [], + columns, + data, + emptyMessage = 'No data', + actions = [] }: DataTableProps) { - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - }); + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel() + }); - return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ); - })} - - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + return ( +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + - - )) - ) : ( - - - {emptyMessage} - - - )} - -
+ + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} - {/* Actions */} - - - - - - - - {actions.map((action) => ( - - - - ))} - - - -
-
- ); + {/* Actions */} + + + + + + + + {actions.map((action) => ( + + + + ))} + + + + + + )) + ) : ( + + + {emptyMessage} + + + )} + + + + ); } diff --git a/apps/web/features/organizations/settings/general/settings-general-layout.tsx b/apps/web/features/organizations/settings/general/settings-general-layout.tsx index 9b7e174d2..693b0929b 100644 --- a/apps/web/features/organizations/settings/general/settings-general-layout.tsx +++ b/apps/web/features/organizations/settings/general/settings-general-layout.tsx @@ -1,19 +1,19 @@ -import { Page } from "@/features/shell"; +import { Page } from '@/features/shell'; export function SettingsGeneralLayout({ - children, + children }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-

Team Settings

-

All settings for team

+ return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+

Team Settings

+

All settings for team

- {children} -
-
- ); + {children} +
+
+ ); } diff --git a/apps/web/features/organizations/settings/general/settings-general-page-skeleton.tsx b/apps/web/features/organizations/settings/general/settings-general-page-skeleton.tsx index 2c3d35320..aab1cb7a9 100644 --- a/apps/web/features/organizations/settings/general/settings-general-page-skeleton.tsx +++ b/apps/web/features/organizations/settings/general/settings-general-page-skeleton.tsx @@ -1,16 +1,16 @@ -import { SettingsCardSkeleton } from "@voidhash/ui"; -import { SettingsGeneralLayout } from "./settings-general-layout"; +import { SettingsCardSkeleton } from '@voidhash/ui'; +import { SettingsGeneralLayout } from './settings-general-layout'; export function SettingsGeneralPageSkeleton() { - return ( - - - - - ); + return ( + + + + + ); } diff --git a/apps/web/features/organizations/settings/general/settings-general-page.tsx b/apps/web/features/organizations/settings/general/settings-general-page.tsx index 90af026a9..b02a43d4c 100644 --- a/apps/web/features/organizations/settings/general/settings-general-page.tsx +++ b/apps/web/features/organizations/settings/general/settings-general-page.tsx @@ -1,57 +1,57 @@ -import { TeamNameForm } from "./team-name"; -import { TeamDelete } from "./team-delete"; -import { SettingsGeneralLayout } from "./settings-general-layout"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { OrganizationService } from "@/lib/services/organization.service"; -import { Effect } from "effect"; -import { NotFoundError } from "@/lib/effect/errors"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Effect } from 'effect'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { OrganizationService } from '@/lib/services/organization.service'; +import { SettingsGeneralLayout } from './settings-general-layout'; +import { TeamDelete } from './team-delete'; +import { TeamNameForm } from './team-name'; export default async function GeneralSettingsPage({ - params, + params }: { - params: { organizationSlug: string }; + params: { organizationSlug: string }; }) { - const { organizationSlug } = params; - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const activeOrganization = yield* organizationService - .getOrganizationBySlug(organizationSlug) - .pipe( - Effect.catchTags({ - OrganizationNotFound: () => - Effect.fail( - new NotFoundError({ - message: "Organization not found", - }), - ), - }), - ); + const { organizationSlug } = params; + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const activeOrganization = yield* organizationService + .getOrganizationBySlug(organizationSlug) + .pipe( + Effect.catchTags({ + OrganizationNotFound: () => + Effect.fail( + new NotFoundError({ + message: 'Organization not found' + }) + ) + }) + ); - return { activeOrganization }; - }), - ); - }), - ); + return { activeOrganization }; + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { activeOrganization } = data.value; + const { activeOrganization } = data.value; - return ( - - - {/* */} - - - ); + return ( + + + {/* */} + + + ); } diff --git a/apps/web/features/organizations/settings/general/team-delete.tsx b/apps/web/features/organizations/settings/general/team-delete.tsx index f55f0d52b..a5bd9cf80 100644 --- a/apps/web/features/organizations/settings/general/team-delete.tsx +++ b/apps/web/features/organizations/settings/general/team-delete.tsx @@ -1,84 +1,84 @@ -"use client"; +'use client'; -import { DeleteOrganizationModal } from "@/features/organizations/delete-organization-modal"; +import { useQueryClient } from '@tanstack/react-query'; import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardFooter, - Button, -} from "@voidhash/ui"; -import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; -import { useAction } from "next-safe-action/hooks"; -import { deleteOrganizationAction } from "@/lib/nextjs/server-actions"; -import { toast } from "sonner"; -import { useQueryClient } from "@tanstack/react-query"; -import { useTRPC } from "@/features/trpc/react"; + Button, + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@voidhash/ui'; +import { useParams, useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { DeleteOrganizationModal } from '@/features/organizations/delete-organization-modal'; +import { useTRPC } from '@/features/trpc/react'; +import { deleteOrganizationAction } from '@/lib/nextjs/server-actions'; export function TeamDelete({ organizationId }: { organizationId: string }) { - const { organizationSlug } = useParams(); - const router = useRouter(); - const queryClient = useQueryClient(); - const trpc = useTRPC(); + const { organizationSlug } = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const trpc = useTRPC(); - const { execute, isPending } = useAction(deleteOrganizationAction, { - onSuccess: () => { - toast.success("Team deleted successfully"); - queryClient.invalidateQueries({ - queryKey: trpc.pathKey(), - }); - router.push("/"); - }, - onError: (error) => { - toast.error(error.error.serverError); - }, - }); + const { execute, isPending } = useAction(deleteOrganizationAction, { + onSuccess: () => { + toast.success('Team deleted successfully'); + queryClient.invalidateQueries({ + queryKey: trpc.pathKey() + }); + router.push('/'); + }, + onError: (error) => { + toast.error(error.error.serverError); + } + }); - const handleDelete = () => { - execute({ - organizationId, - }); - }; + const handleDelete = () => { + execute({ + organizationId + }); + }; - // Delete modal - const [deleteModalOpen, setDeleteModalOpen] = useState(false); + // Delete modal + const [deleteModalOpen, setDeleteModalOpen] = useState(false); - if (typeof organizationSlug !== "string") { - return null; - } + if (typeof organizationSlug !== 'string') { + return null; + } - return ( - - - Delete Team - - Permanently delete your team and all associated data. This action is - irreversible. - - - -
-
- setDeleteModalOpen(false)} - onDelete={handleDelete} - key={deleteModalOpen ? "open" : "closed"} - trigger={ - - } - organizationSlug={organizationSlug} - /> -
-
-
- ); + return ( + + + Delete Team + + Permanently delete your team and all associated data. This action is + irreversible. + + + +
+
+ setDeleteModalOpen(false)} + onDelete={handleDelete} + open={deleteModalOpen} + organizationSlug={organizationSlug} + trigger={ + + } + /> +
+ + + ); } diff --git a/apps/web/features/organizations/settings/general/team-name.tsx b/apps/web/features/organizations/settings/general/team-name.tsx index 8798e465a..a0a202fa6 100644 --- a/apps/web/features/organizations/settings/general/team-name.tsx +++ b/apps/web/features/organizations/settings/general/team-name.tsx @@ -1,118 +1,120 @@ -"use client"; +'use client'; -import { updateOrganizationAction } from "@/lib/nextjs/server-actions"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQueryClient } from '@tanstack/react-query'; +import type { Organization } from '@voidhash/db'; import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - FormField, - FormItem, - FormControl, - Input, - FormMessage, - CardFooter, - Button, - Form, -} from "@voidhash/ui"; -import { useAction } from "next-safe-action/hooks"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { toast } from "sonner"; -import { useRouter } from "next/navigation"; -import { useTRPC } from "@/features/trpc/react"; -import { useQueryClient } from "@tanstack/react-query"; -import type { Organization } from "@voidhash/db"; + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Form, + FormControl, + FormField, + FormItem, + FormMessage, + Input +} from '@voidhash/ui'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { useTRPC } from '@/features/trpc/react'; +import { updateOrganizationAction } from '@/lib/nextjs/server-actions'; const updateTeamNameSchema = z.object({ - name: z - .string() - .min(1, "Team name is required") - .max(32, "Team name must be less than 32 characters"), + name: z + .string() + .min(1, 'Team name is required') + .max(32, 'Team name must be less than 32 characters') }); type UpdateTeamNameForm = z.infer; export function TeamNameForm({ organization }: { organization: Organization }) { - const form = useForm({ - resolver: zodResolver(updateTeamNameSchema), - defaultValues: { - name: organization?.name, - }, - }); + const form = useForm({ + resolver: zodResolver(updateTeamNameSchema), + defaultValues: { + name: organization?.name + } + }); - const queryClient = useQueryClient(); - const trpc = useTRPC(); + const queryClient = useQueryClient(); + const trpc = useTRPC(); - const router = useRouter(); + const router = useRouter(); - const { execute: updateTeamName, isPending } = useAction( - updateOrganizationAction, - { - onSuccess: () => { - toast.success("Team name updated successfully"); - queryClient.invalidateQueries({ - queryKey: trpc.pathKey(), - }); - router.refresh(); - }, - onError: (error) => { - toast.error(error.error.serverError); - }, - } - ); + const { execute: updateTeamName, isPending } = useAction( + updateOrganizationAction, + { + onSuccess: () => { + toast.success('Team name updated successfully'); + queryClient.invalidateQueries({ + queryKey: trpc.pathKey() + }); + router.refresh(); + }, + onError: (error) => { + toast.error(error.error.serverError); + } + } + ); - const onSubmit = (data: UpdateTeamNameForm) => { - if (!organization) return; - updateTeamName({ - organizationId: organization.id, - name: data.name, - }); - }; + const onSubmit = (data: UpdateTeamNameForm) => { + if (!organization) { + return; + } + updateTeamName({ + organizationId: organization.id, + name: data.name + }); + }; - return ( -
- - - - Team name - - This is your team's visible name within Voidhash. For - example, the name of your company or department. - - - - ( - - - - - - - )} - /> - - -
- Please use 32 characters at maximum. -
-
- -
-
-
-
- - ); + return ( +
+ + + + Team name + + This is your team's visible name within Voidhash. For + example, the name of your company or department. + + + + ( + + + + + + + )} + /> + + +
+ Please use 32 characters at maximum. +
+
+ +
+
+
+
+ + ); } diff --git a/apps/web/features/organizations/settings/members/columns.tsx b/apps/web/features/organizations/settings/members/columns.tsx index cdf2faaef..8b0ce76b6 100644 --- a/apps/web/features/organizations/settings/members/columns.tsx +++ b/apps/web/features/organizations/settings/members/columns.tsx @@ -1,34 +1,34 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { format } from "date-fns"; +import type { ColumnDef } from '@tanstack/react-table'; +import { format } from 'date-fns'; const formatDate = (date: Date) => { - return format(date, "MM/dd/yyyy"); + return format(date, 'MM/dd/yyyy'); }; // This type is used to define the shape of our data. // You can use a Zod schema here if you want. -export const membersColumns: ColumnDef[] = [ - { - accessorKey: "name", - header: "Name", - }, - { - accessorKey: "email", - header: "Email", - }, - { - accessorKey: "role", - header: "Role", - cell: (props) => { - return {props.getValue() as string}; - }, - }, +export const membersColumns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name' + }, + { + accessorKey: 'email', + header: 'Email' + }, + { + accessorKey: 'role', + header: 'Role', + cell: (props) => { + return {props.getValue() as string}; + } + }, - { - accessorKey: "invitedAt", - header: "Joined", - cell: (props) => { - return {formatDate(props.getValue() as Date)}; - }, - }, + { + accessorKey: 'invitedAt', + header: 'Joined', + cell: (props) => { + return {formatDate(props.getValue() as Date)}; + } + } ]; diff --git a/apps/web/features/organizations/settings/pending-invites/columns.tsx b/apps/web/features/organizations/settings/pending-invites/columns.tsx index 274197a53..a6ee0e55e 100644 --- a/apps/web/features/organizations/settings/pending-invites/columns.tsx +++ b/apps/web/features/organizations/settings/pending-invites/columns.tsx @@ -1,50 +1,50 @@ -import type { ColumnDef } from "@tanstack/react-table"; -import { Badge, CopyText } from "@voidhash/ui"; -import { format } from "date-fns"; +import type { ColumnDef } from '@tanstack/react-table'; +import { Badge, CopyText } from '@voidhash/ui'; +import { format } from 'date-fns'; const formatDate = (date: Date) => { - return format(date, "MM/dd/yyyy"); + return format(date, 'MM/dd/yyyy'); }; // This type is used to define the shape of our data. // You can use a Zod schema here if you want. -export const invitationsColumns: ColumnDef[] = [ - { - accessorKey: "name", - header: "Name", - cell: (props) => { - return {props.getValue() as string}; - }, - }, - { - accessorKey: "status", - header: "Status", - cell: (props) => { - return ( - - {props.getValue() as string} - - ); - }, - }, - { - accessorKey: "invitedAt", - header: "Invited", - cell: (props) => { - return {formatDate(props.getValue() as Date)}; - }, - }, - { - accessorKey: "id", - header: "Invite link", - cell: (props) => { - return ( - - ); - }, - }, +export const invitationsColumns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + cell: (props) => { + return {props.getValue() as string}; + } + }, + { + accessorKey: 'status', + header: 'Status', + cell: (props) => { + return ( + + {props.getValue() as string} + + ); + } + }, + { + accessorKey: 'invitedAt', + header: 'Invited', + cell: (props) => { + return {formatDate(props.getValue() as Date)}; + } + }, + { + accessorKey: 'id', + header: 'Invite link', + cell: (props) => { + return ( + + ); + } + } ]; diff --git a/apps/web/features/paywall-locations/create-paywall-location-modal-button.tsx b/apps/web/features/paywall-locations/create-paywall-location-modal-button.tsx index 4446463da..a71f4afa2 100644 --- a/apps/web/features/paywall-locations/create-paywall-location-modal-button.tsx +++ b/apps/web/features/paywall-locations/create-paywall-location-modal-button.tsx @@ -1,27 +1,28 @@ -"use client"; -import { useState } from "react"; -import { Button } from "@voidhash/ui/button"; -import { CreatePaywallLocationModal } from "./create-paywall-location-modal"; -import type { Paywall } from "@voidhash/db"; +'use client'; +import type { Paywall } from '@voidhash/db'; +import { Button } from '@voidhash/ui/button'; +import { useState } from 'react'; +import { CreatePaywallLocationModal } from './create-paywall-location-modal'; export function CreatePaywallLocationModalButton({ - projectId, - paywalls, -}: { projectId: string; paywalls: Paywall[] }) { - const [open, setOpen] = useState(false); + projectId, + paywalls +}: { + projectId: string; + paywalls: Paywall[]; +}) { + const [open, setOpen] = useState(false); - return ( - <> - setOpen(false)} - paywalls={paywalls} - trigger={ - - } - projectId={projectId} - onSuccess={() => setOpen(false)} - /> - - ); + return ( + setOpen(false)} + onSuccess={() => setOpen(false)} + open={open} + paywalls={paywalls} + projectId={projectId} + trigger={ + + } + /> + ); } diff --git a/apps/web/features/paywall-locations/create-paywall-location-modal.tsx b/apps/web/features/paywall-locations/create-paywall-location-modal.tsx index c0aa667a1..bf9d8a4ef 100644 --- a/apps/web/features/paywall-locations/create-paywall-location-modal.tsx +++ b/apps/web/features/paywall-locations/create-paywall-location-modal.tsx @@ -1,266 +1,267 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui/button"; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { Paywall } from '@voidhash/db'; import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + cn, + InfoTooltip, + Popover, + PopoverContent, + PopoverTrigger +} from '@voidhash/ui'; +import { Button } from '@voidhash/ui/button'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; -import { useAction } from "next-safe-action/hooks"; -import { createPaywallLocationAction } from "@/lib/nextjs/server-actions"; -import { useRouter } from "next/navigation"; -import { InferSafeActionFnResult } from "next-safe-action"; + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@voidhash/ui/dialog'; import { - cn, - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - InfoTooltip, - Popover, - PopoverContent, - PopoverTrigger, -} from "@voidhash/ui"; -import { Check, ChevronsUpDown } from "lucide-react"; -import { useEffect } from "react"; -import type { Paywall } from "@voidhash/db"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import type { InferSafeActionFnResult } from 'next-safe-action'; +import { useAction } from 'next-safe-action/hooks'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { createPaywallLocationAction } from '@/lib/nextjs/server-actions'; const createPaywallLocationSchema = z.object({ - name: z - .string() - .min(3, "Name must be at least 3 characters long") - .max(32, "Name must be less than 32 characters"), - slug: z - .string() - .min(3, "Slug must be at least 3 characters long") - .max(32, "Slug must be less than 32 characters") - .regex( - /^[a-z0-9_-]+$/, - "Slug must contain only lowercase letters, numbers, underscores, and hyphens" - ), - defaultPaywallId: z.string().min(1, "Default paywall is required"), + name: z + .string() + .min(3, 'Name must be at least 3 characters long') + .max(32, 'Name must be less than 32 characters'), + slug: z + .string() + .min(3, 'Slug must be at least 3 characters long') + .max(32, 'Slug must be less than 32 characters') + .regex( + /^[a-z0-9_-]+$/, + 'Slug must contain only lowercase letters, numbers, underscores, and hyphens' + ), + defaultPaywallId: z.string().min(1, 'Default paywall is required') }); type CreatePaywallLocationForm = z.infer; type PaywallLocation = InferSafeActionFnResult< - typeof createPaywallLocationAction ->["data"]; + typeof createPaywallLocationAction +>['data']; interface CreatePaywallLocationModalProps { - open: boolean; - onClose: () => void; - paywalls: Paywall[]; - trigger: React.ReactNode; - projectId: string; - onSuccess?: (paywallLocation: PaywallLocation) => void; + open: boolean; + onClose: () => void; + paywalls: Paywall[]; + trigger: React.ReactNode; + projectId: string; + onSuccess?: (paywallLocation: PaywallLocation) => void; } export function CreatePaywallLocationModal({ - open, - onClose, - trigger, - paywalls, - projectId, - onSuccess, + open, + onClose, + trigger, + paywalls, + projectId, + onSuccess }: CreatePaywallLocationModalProps) { - const router = useRouter(); - const form = useForm({ - resolver: zodResolver(createPaywallLocationSchema), - defaultValues: { - name: "", - slug: "", - defaultPaywallId: paywalls[0]?.id || "", - }, - }); + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(createPaywallLocationSchema), + defaultValues: { + name: '', + slug: '', + defaultPaywallId: paywalls[0]?.id || '' + } + }); - const { execute, isPending } = useAction(createPaywallLocationAction, { - onSuccess: (res) => { - if (res.data) { - toast.success("Paywall location created successfully"); - onSuccess?.(res.data); - router.refresh(); - handleOpenChange(false); - } - }, - onError: (error) => { - toast.error( - error.error.serverError || "Failed to create paywall location" - ); - }, - }); + const { execute, isPending } = useAction(createPaywallLocationAction, { + onSuccess: (res) => { + if (res.data) { + toast.success('Paywall location created successfully'); + onSuccess?.(res.data); + router.refresh(); + handleOpenChange(false); + } + }, + onError: (error) => { + toast.error( + error.error.serverError || 'Failed to create paywall location' + ); + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - form.reset(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + form.reset(); + } + }; - const onSubmit = (data: CreatePaywallLocationForm) => { - execute({ ...data, projectId }); - }; + const onSubmit = (data: CreatePaywallLocationForm) => { + execute({ ...data, projectId }); + }; - useEffect(() => { - if (paywalls.length > 0) { - form.setValue("defaultPaywallId", paywalls[0]?.id || ""); - } - }, [paywalls, form]); + useEffect(() => { + if (paywalls.length > 0) { + form.setValue('defaultPaywallId', paywalls[0]?.id || ''); + } + }, [paywalls, form]); - return ( - - {trigger} - - - Create Paywall Location - -
- - ( - - Name - - - - - - )} - /> - ( - - - Slug (ID) - - - - - - - - )} - /> - ( - - - Paywall{" "} - - - - - - - - - - - - - - No paywalls found. - - {paywalls.map((paywall) => ( - { - form.setValue( - "defaultPaywallId", - paywall.id - ); - }} - > - {paywall.name} - - - ))} - - - - - - - - - )} - /> - - - - - -
-
- ); + return ( + + {trigger} + + + Create Paywall Location + +
+ + ( + + Name + + + + + + )} + /> + ( + + + Slug (ID) + + + + + + + + )} + /> + ( + + + Paywall{' '} + + + + + + + {/** biome-ignore lint/a11y/useSemanticElements: custom component */} + + + + + + + + No paywalls found. + + {paywalls.map((paywall) => ( + { + form.setValue( + 'defaultPaywallId', + paywall.id + ); + }} + value={paywall.name} + > + {paywall.name} + + + ))} + + + + + + + + + )} + /> + + + + + +
+
+ ); } diff --git a/apps/web/features/paywall-locations/paywall-location-record-skeleton.tsx b/apps/web/features/paywall-locations/paywall-location-record-skeleton.tsx index e6c8a1f8e..55725ad6a 100644 --- a/apps/web/features/paywall-locations/paywall-location-record-skeleton.tsx +++ b/apps/web/features/paywall-locations/paywall-location-record-skeleton.tsx @@ -1,25 +1,25 @@ -"use client"; +'use client'; -import { Skeleton } from "@voidhash/ui"; +import { Skeleton } from '@voidhash/ui'; export function PaywallLocationRecordSkeleton() { - return ( -
-
-
-
-
- -
-
- -
-
-
-
- -
-
-
- ); + return ( +
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ ); } diff --git a/apps/web/features/paywall-locations/paywall-location-record.tsx b/apps/web/features/paywall-locations/paywall-location-record.tsx index 1fe8d4af5..c4ef82a82 100644 --- a/apps/web/features/paywall-locations/paywall-location-record.tsx +++ b/apps/web/features/paywall-locations/paywall-location-record.tsx @@ -1,123 +1,123 @@ -"use client"; -import { deletePaywallLocationAction } from "@/lib/nextjs/server-actions"; +'use client'; +import type { Paywall, PaywallLocation } from '@voidhash/db'; import { - DropdownMenu, - DropdownMenuTrigger, - Button, - DropdownMenuContent, - DropdownMenuItem, - useConfirmDialog, - Badge, - TooltipTrigger, - Tooltip, - TooltipContent, - TooltipProvider, -} from "@voidhash/ui"; -import { useAction } from "next-safe-action/hooks"; -import { CopyIcon, EllipsisVerticalIcon } from "lucide-react"; -import { toast } from "sonner"; -import { useRouter } from "next/navigation"; -import type { PaywallLocation, Paywall } from "@voidhash/db"; + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + useConfirmDialog +} from '@voidhash/ui'; +import { CopyIcon, EllipsisVerticalIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { toast } from 'sonner'; +import { deletePaywallLocationAction } from '@/lib/nextjs/server-actions'; // import { EditProductModal } from "./edit-product-modal"; export function PaywallLocationRecord({ - paywallLocation, - paywalls, + paywallLocation, + paywalls }: { - paywallLocation: PaywallLocation; - organizationSlug: string; - projectSlug: string; - paywalls: Paywall[]; + paywallLocation: PaywallLocation; + organizationSlug: string; + projectSlug: string; + paywalls: Paywall[]; }) { - const router = useRouter(); - // const [setOpenEditModal] = useState(false); + const router = useRouter(); + // const [setOpenEditModal] = useState(false); - const { execute: deletePaywallLocation, isPending } = useAction( - deletePaywallLocationAction, - { - onSuccess: () => { - toast.success(`Paywall location was successfully deleted`); - router.refresh(); - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to delete the paywall location. Please try again.` - ); - }, - } - ); + const { execute: deletePaywallLocation, isPending } = useAction( + deletePaywallLocationAction, + { + onSuccess: () => { + toast.success('Paywall location was successfully deleted'); + router.refresh(); + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + 'Failed to delete the paywall location. Please try again.' + ); + } + } + ); - const { ConfirmationDialog, openDialog } = useConfirmDialog(); + const { ConfirmationDialog, openDialog } = useConfirmDialog(); - const handleDeletePaywallLocation = async () => { - const res = await openDialog({ - title: "Delete paywall location", - description: `Are you sure you want to delete this paywall location?`, - }); + const handleDeletePaywallLocation = async () => { + const res = await openDialog({ + title: 'Delete paywall location', + description: 'Are you sure you want to delete this paywall location?' + }); - if (!res) { - return; - } + if (!res) { + return; + } - deletePaywallLocation({ - paywallLocationId: paywallLocation.id, - }); - }; + deletePaywallLocation({ + paywallLocationId: paywallLocation.id + }); + }; - return ( -
- {/* + {/* */} -
-
-
-
-
{paywallLocation.name}
- - {paywalls.find( - (paywall) => paywall.id === paywallLocation.defaultPaywallId - )?.name ?? "No paywall"} - -
- - {paywallLocation.slug} - -
-
-
- - - - - - -

Click to copy Slug (ID)

-
-
-
+
+
+
+
+
{paywallLocation.name}
+ + {paywalls.find( + (paywall) => paywall.id === paywallLocation.defaultPaywallId + )?.name ?? 'No paywall'} + +
+ + {paywallLocation.slug} + +
+
+
+ + + + + + +

Click to copy Slug (ID)

+
+
+
- - - - - - {/* + + + + + {/* { e.preventDefault(); @@ -127,23 +127,23 @@ export function PaywallLocationRecord({ Edit perk */} - - {isPending ? "Deleting..." : "Delete location"} - - - -
-
- - {/* + {isPending ? 'Deleting...' : 'Delete location'} + + + +
+
+ + {/* setOpenEditModal(false)} product={product} /> */} -
- ); +
+ ); } diff --git a/apps/web/features/paywall-locations/paywall-locations-page-empty-state.tsx b/apps/web/features/paywall-locations/paywall-locations-page-empty-state.tsx index b187538c0..03663d84e 100644 --- a/apps/web/features/paywall-locations/paywall-locations-page-empty-state.tsx +++ b/apps/web/features/paywall-locations/paywall-locations-page-empty-state.tsx @@ -1,47 +1,50 @@ -"use client"; +'use client'; +import type { Paywall } from '@voidhash/db'; import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Button, -} from "@voidhash/ui"; -import { useState } from "react"; -import { CreatePaywallLocationModal } from "./create-paywall-location-modal"; -import type { Paywall } from "@voidhash/db"; + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@voidhash/ui'; +import { useState } from 'react'; +import { CreatePaywallLocationModal } from './create-paywall-location-modal'; export function PaywallLocationsPageEmptyState({ - projectId, - paywalls, -}: { projectId: string; paywalls: Paywall[] }) { - const [open, setOpen] = useState(false); + projectId, + paywalls +}: { + projectId: string; + paywalls: Paywall[]; +}) { + const [open, setOpen] = useState(false); - return ( - - - No paywall locations yet - - Paywall locations are places across your app where you show a paywall - to the customer. This allows you to switch between paywalls without - having to change the code. - - - - setOpen(false)} - trigger={ - - } - projectId={projectId} - onSuccess={() => setOpen(false)} - /> - - - ); + return ( + + + No paywall locations yet + + Paywall locations are places across your app where you show a paywall + to the customer. This allows you to switch between paywalls without + having to change the code. + + + + setOpen(false)} + onSuccess={() => setOpen(false)} + open={open} + paywalls={paywalls} + projectId={projectId} + trigger={ + + } + /> + + + ); } diff --git a/apps/web/features/paywall-locations/paywall-locations-page-skeleton.tsx b/apps/web/features/paywall-locations/paywall-locations-page-skeleton.tsx index dd97a748e..0654c989a 100644 --- a/apps/web/features/paywall-locations/paywall-locations-page-skeleton.tsx +++ b/apps/web/features/paywall-locations/paywall-locations-page-skeleton.tsx @@ -1,26 +1,27 @@ -import { Card } from "@voidhash/ui"; -import { PaywallLocationRecordSkeleton } from "./paywall-location-record-skeleton"; +import { Card } from '@voidhash/ui'; +import { PaywallLocationRecordSkeleton } from './paywall-location-record-skeleton'; export function PaywallLocationsPageSkeleton() { - return ( -
-
-
-

- Paywall Locations -

-

- Places throughout your app where paywalls can be shown. -

-
-
-
- - {Array.from({ length: 3 }).map((_, index) => ( - - ))} - -
-
- ); + return ( +
+
+
+

+ Paywall Locations +

+

+ Places throughout your app where paywalls can be shown. +

+
+
+
+ + {Array.from({ length: 3 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: just a skeleton + + ))} + +
+
+ ); } diff --git a/apps/web/features/paywall-locations/paywall-locations-page.tsx b/apps/web/features/paywall-locations/paywall-locations-page.tsx index 720158493..953be43b2 100644 --- a/apps/web/features/paywall-locations/paywall-locations-page.tsx +++ b/apps/web/features/paywall-locations/paywall-locations-page.tsx @@ -1,113 +1,113 @@ -import { Card } from "@voidhash/ui"; -import { CreatePaywallLocationModalButton } from "./create-paywall-location-modal-button"; -import { PaywallLocationsPageEmptyState } from "./paywall-locations-page-empty-state"; -import { PaywallLocationRecord } from "./paywall-location-record"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { PaywallLocationService } from "@/lib/services/paywall-location.service"; -import { Effect } from "effect"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { NotFoundError } from "@/lib/effect/errors"; -import { ProjectService } from "@/lib/services/project.service"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Card } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { PaywallLocationService } from '@/lib/services/paywall-location.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { CreatePaywallLocationModalButton } from './create-paywall-location-modal-button'; +import { PaywallLocationRecord } from './paywall-location-record'; +import { PaywallLocationsPageEmptyState } from './paywall-locations-page-empty-state'; export async function PaywallLocationsPage({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string; - projectSlug: string; + organizationSlug: string; + projectSlug: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const projectService = yield* ProjectService; - const paywallLocationService = yield* PaywallLocationService; - const paywallService = yield* PaywallService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - organizationSlug, - projectSlug, - }); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - const paywalls = yield* paywallService.getPaywalls(project.id); - const paywallLocations = - yield* paywallLocationService.getPaywallLocations(project.id); - return { project, paywalls, paywallLocations }; - }) - ); - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const projectService = yield* ProjectService; + const paywallLocationService = yield* PaywallLocationService; + const paywallService = yield* PaywallService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + organizationSlug, + projectSlug + }); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + const paywalls = yield* paywallService.getPaywalls(project.id); + const paywallLocations = + yield* paywallLocationService.getPaywallLocations(project.id); + return { project, paywalls, paywallLocations }; + }) + ); + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { project, paywalls, paywallLocations } = data.value; + const { project, paywalls, paywallLocations } = data.value; - return ( -
-
-
-

- Paywall Locations -

-

- Places throughout your app where paywalls can be shown. -

-
- {paywallLocations.length > 0 && ( - - )} -
+ return ( +
+
+
+

+ Paywall Locations +

+

+ Places throughout your app where paywalls can be shown. +

+
+ {paywallLocations.length > 0 && ( + + )} +
-
- {paywallLocations.length === 0 ? ( - - ) : ( - - {paywallLocations.map((paywallLocation) => ( - - ))} - - )} -
-
- ); +
+ {paywallLocations.length === 0 ? ( + + ) : ( + + {paywallLocations.map((paywallLocation) => ( + + ))} + + )} +
+
+ ); } diff --git a/apps/web/features/paywalls/create-paywall-modal-button.tsx b/apps/web/features/paywalls/create-paywall-modal-button.tsx index 29a0d3305..34445c353 100644 --- a/apps/web/features/paywalls/create-paywall-modal-button.tsx +++ b/apps/web/features/paywalls/create-paywall-modal-button.tsx @@ -1,20 +1,18 @@ -"use client"; -import { useState } from "react"; -import { Button } from "@voidhash/ui/button"; -import { CreatePaywallModal } from "./create-paywall-modal"; +'use client'; +import { Button } from '@voidhash/ui/button'; +import { useState } from 'react'; +import { CreatePaywallModal } from './create-paywall-modal'; export function CreatePaywallModalButton({ projectId }: { projectId: string }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); - return ( - <> - setOpen(false)} - trigger={} - projectId={projectId} - onSuccess={() => setOpen(false)} - /> - - ); + return ( + setOpen(false)} + onSuccess={() => setOpen(false)} + open={open} + projectId={projectId} + trigger={} + /> + ); } diff --git a/apps/web/features/paywalls/create-paywall-modal.tsx b/apps/web/features/paywalls/create-paywall-modal.tsx index 45d98e84c..7ab678013 100644 --- a/apps/web/features/paywalls/create-paywall-modal.tsx +++ b/apps/web/features/paywalls/create-paywall-modal.tsx @@ -1,130 +1,130 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui/button"; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@voidhash/ui/button'; import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@voidhash/ui/dialog'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; -import { useAction } from "next-safe-action/hooks"; -import { createPaywallAction } from "@/lib/nextjs/server-actions"; -import { useRouter } from "next/navigation"; -import { InferSafeActionFnResult } from "next-safe-action"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { useRouter } from 'next/navigation'; +import type { InferSafeActionFnResult } from 'next-safe-action'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { createPaywallAction } from '@/lib/nextjs/server-actions'; const createPaywallSchema = z.object({ - name: z - .string() - .min(3, "Name must be at least 3 characters long") - .max(32, "Name must be less than 32 characters"), + name: z + .string() + .min(3, 'Name must be at least 3 characters long') + .max(32, 'Name must be less than 32 characters') }); type CreatePaywallForm = z.infer; -type Paywall = InferSafeActionFnResult["data"]; +type Paywall = InferSafeActionFnResult['data']; interface CreatePaywallModalProps { - open: boolean; - onClose: () => void; - trigger: React.ReactNode; - projectId: string; - onSuccess?: (paywall: Paywall) => void; + open: boolean; + onClose: () => void; + trigger: React.ReactNode; + projectId: string; + onSuccess?: (paywall: Paywall) => void; } export function CreatePaywallModal({ - open, - onClose, - trigger, - projectId, - onSuccess, + open, + onClose, + trigger, + projectId, + onSuccess }: CreatePaywallModalProps) { - const router = useRouter(); - const form = useForm({ - resolver: zodResolver(createPaywallSchema), - defaultValues: { - name: "", - }, - }); + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(createPaywallSchema), + defaultValues: { + name: '' + } + }); - const { execute, isPending } = useAction(createPaywallAction, { - onSuccess: (res) => { - if (res.data) { - toast.success("Paywall created successfully"); - onSuccess?.(res.data); - router.refresh(); - handleOpenChange(false); - } - }, - onError: (error) => { - toast.error(error.error.serverError || "Failed to create paywall"); - }, - }); + const { execute, isPending } = useAction(createPaywallAction, { + onSuccess: (res) => { + if (res.data) { + toast.success('Paywall created successfully'); + onSuccess?.(res.data); + router.refresh(); + handleOpenChange(false); + } + }, + onError: (error) => { + toast.error(error.error.serverError || 'Failed to create paywall'); + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - form.reset(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + form.reset(); + } + }; - const onSubmit = (data: CreatePaywallForm) => { - execute({ ...data, projectId }); - }; + const onSubmit = (data: CreatePaywallForm) => { + execute({ ...data, projectId }); + }; - return ( - - {trigger} - - - Create Paywall - {/* + return ( + + {trigger} + + + Create Paywall + {/* Create a new paywall for your project. */} - -
- - ( - - Name - - - - - - )} - /> - - - - - -
-
- ); +
+
+ + ( + + Name + + + + + + )} + /> + + + + + +
+
+ ); } diff --git a/apps/web/features/paywalls/paywall-detail-add-product-button.tsx b/apps/web/features/paywalls/paywall-detail-add-product-button.tsx index 7d168a7b6..774cf327f 100644 --- a/apps/web/features/paywalls/paywall-detail-add-product-button.tsx +++ b/apps/web/features/paywalls/paywall-detail-add-product-button.tsx @@ -1,79 +1,78 @@ -"use client"; +'use client'; +import type { Product } from '@voidhash/db'; import { - Button, - cn, - CommandEmpty, - Command, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - Popover, - PopoverContent, - PopoverTrigger, -} from "@voidhash/ui"; -import { Check } from "lucide-react"; -import { useState } from "react"; -import type { Product } from "@voidhash/db"; + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + cn, + Popover, + PopoverContent, + PopoverTrigger +} from '@voidhash/ui'; +import { Check } from 'lucide-react'; +import { useState } from 'react'; export function PaywallDetailAddProductButton({ - products, - variant = "default", - onAdd, + products, + variant = 'default', + onAdd }: { - products: Product[]; - variant?: "default" | "secondary" | "outline"; - onAdd: (productId: string) => void; + products: Product[]; + variant?: 'default' | 'secondary' | 'outline'; + onAdd: (productId: string) => void; }) { - const [open, setOpen] = useState(false); - const [value, setValue] = useState(""); + const [open, setOpen] = useState(false); + const [value, setValue] = useState(''); - const handleSelect = (productId: string) => { - onAdd(productId); - setValue(productId); - setOpen(false); - }; + const handleSelect = (productId: string) => { + onAdd(productId); + setValue(productId); + setOpen(false); + }; - return ( - <> - - - - - - - - - No products found. - - {products.map((product) => ( - { - handleSelect(product.id); - setValue(""); - setOpen(false); - }} - > - - {product.name} - - ))} - - - - - - - ); + return ( + + + {/** biome-ignore lint/a11y/useSemanticElements: shadcn custom component */} + + + + + + + No products found. + + {products.map((product) => ( + { + handleSelect(product.id); + setValue(''); + setOpen(false); + }} + value={product.id} + > + + {product.name} + + ))} + + + + + + ); } diff --git a/apps/web/features/paywalls/paywall-detail-page-editor.tsx b/apps/web/features/paywalls/paywall-detail-page-editor.tsx index e89f3f6cb..98a05a395 100644 --- a/apps/web/features/paywalls/paywall-detail-page-editor.tsx +++ b/apps/web/features/paywalls/paywall-detail-page-editor.tsx @@ -1,219 +1,218 @@ -"use client"; +'use client'; -import { Button, Card, CardHeader, CardTitle, CardContent } from "@voidhash/ui"; -import { PaywallDetailAddProductButton } from "./paywall-detail-add-product-button"; -import { useRouter } from "next/navigation"; -import { useAction } from "next-safe-action/hooks"; -import { toast } from "sonner"; -import { updatePaywallAction } from "@/lib/nextjs/server-actions"; - -import { useState } from "react"; import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors +} from '@dnd-kit/core'; import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { PaywallDetailProductRecord } from "./paywall-detail-product-record"; -import { Schema } from "effect"; -import type { Paywall, PaywallProduct, Product } from "@voidhash/db"; -import { updatePaywallInputSchema } from "@/lib/nextjs/schema"; + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy +} from '@dnd-kit/sortable'; +import type { Paywall, PaywallProduct, Product } from '@voidhash/db'; +import { Button, Card, CardContent, CardHeader, CardTitle } from '@voidhash/ui'; +import type { Schema } from 'effect'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import type { updatePaywallInputSchema } from '@/lib/nextjs/schema'; +import { updatePaywallAction } from '@/lib/nextjs/server-actions'; +import { PaywallDetailAddProductButton } from './paywall-detail-add-product-button'; +import { PaywallDetailProductRecord } from './paywall-detail-product-record'; type UpdatePaywallInput = Schema.Schema.Type; type UpdatePaywallProduct = NonNullable< - UpdatePaywallInput["paywallProducts"] + UpdatePaywallInput['paywallProducts'] >[number]; const reorderProducts = ( - items: Omit[] + items: Omit[] ): UpdatePaywallProduct[] => { - return [...items.map((item, index) => ({ ...item, order: index }))]; + return [...items.map((item, index) => ({ ...item, order: index }))]; }; export function PaywallDetailPageEditor({ - paywall, - initialPaywallProducts, - products, + paywall, + initialPaywallProducts, + products }: { - paywall: Paywall; - initialPaywallProducts: PaywallProduct[]; - products: Product[]; + paywall: Paywall; + initialPaywallProducts: PaywallProduct[]; + products: Product[]; }) { - const router = useRouter(); - - const [paywallProducts, setPaywallProducts] = useState< - UpdatePaywallProduct[] - >(initialPaywallProducts); - - const productsWithoutAddedProducts = products.filter( - (product) => - !paywallProducts.some( - (paywallProduct) => paywallProduct.productId === product.id - ) - ); - - const { execute, isPending } = useAction(updatePaywallAction, { - onSuccess: () => { - toast.success("Paywall saved successfully"); - router.refresh(); - }, - onError: (error) => { - toast.error(error.error.serverError || "Failed to create perk"); - }, - }); - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - setPaywallProducts((items) => { - const oldIndex = items.findIndex( - (item) => item.productId === active.id - ); - const newIndex = items.findIndex((item) => item.productId === over.id); - return reorderProducts(arrayMove(items, oldIndex, newIndex)); - }); - } - }; - - const handleAddPaywallProduct = (productId: string) => { - const product = products.find((p) => p.id === productId); - if (!product) { - toast.error("Product not found"); - return; - } - setPaywallProducts((prevProducts) => { - const newProduct = { - productId, - displayName: product.name, - order: prevProducts.length, - enableNativePurchase: true, - enableWebCheckout: false, - webCheckoutPaymentProviderConfigurationProductId: null, - }; - return reorderProducts([...prevProducts, newProduct]); - }); - }; - - const handleUpdatePaywallProduct = (paywallProduct: UpdatePaywallProduct) => { - setPaywallProducts((prevProducts) => - reorderProducts( - prevProducts.map((p) => { - if (p.productId === paywallProduct.productId) { - return { ...p, ...paywallProduct }; // Ensure order is preserved or updated correctly if part of paywallProduct - } - return p; - }) - ) - ); - }; - - const handleRemovePaywallProduct = (productId: string) => { - setPaywallProducts((prevProducts) => - reorderProducts(prevProducts.filter((p) => p.productId !== productId)) - ); - }; - - const onSubmit = () => { - execute({ - paywallProducts: paywallProducts, - paywallId: paywall.id, - }); - }; - - return ( -
-
-

{paywall.name}

- - {/* */} -
- -
- - - Products - - - {/* Emtpy State */} - {paywallProducts.length === 0 && ( -
-
- This paywall does not have any products added yet. -
-
- handleAddPaywallProduct(productId)} - /> -
-
- )} - - - {paywallProducts.length > 0 && ( -
- p.productId)} - strategy={verticalListSortingStrategy} - > - {paywallProducts.map((paywallProduct) => { - const product = products.find( - (p) => p.id === paywallProduct.productId - ); - if (!product) { - return null; - } - return ( - - handleRemovePaywallProduct(paywallProduct.productId) - } - /> - ); - })} - -
- handleAddPaywallProduct(productId)} - variant="outline" - /> -
-
- )} -
-
-
-
-
- ); + const router = useRouter(); + + const [paywallProducts, setPaywallProducts] = useState< + UpdatePaywallProduct[] + >(initialPaywallProducts); + + const productsWithoutAddedProducts = products.filter( + (product) => + !paywallProducts.some( + (paywallProduct) => paywallProduct.productId === product.id + ) + ); + + const { execute, isPending } = useAction(updatePaywallAction, { + onSuccess: () => { + toast.success('Paywall saved successfully'); + router.refresh(); + }, + onError: (error) => { + toast.error(error.error.serverError || 'Failed to create perk'); + } + }); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setPaywallProducts((items) => { + const oldIndex = items.findIndex( + (item) => item.productId === active.id + ); + const newIndex = items.findIndex((item) => item.productId === over.id); + return reorderProducts(arrayMove(items, oldIndex, newIndex)); + }); + } + }; + + const handleAddPaywallProduct = (productId: string) => { + const product = products.find((p) => p.id === productId); + if (!product) { + toast.error('Product not found'); + return; + } + setPaywallProducts((prevProducts) => { + const newProduct = { + productId, + displayName: product.name, + order: prevProducts.length, + enableNativePurchase: true, + enableWebCheckout: false, + webCheckoutPaymentProviderConfigurationProductId: null + }; + return reorderProducts([...prevProducts, newProduct]); + }); + }; + + const handleUpdatePaywallProduct = (paywallProduct: UpdatePaywallProduct) => { + setPaywallProducts((prevProducts) => + reorderProducts( + prevProducts.map((p) => { + if (p.productId === paywallProduct.productId) { + return { ...p, ...paywallProduct }; // Ensure order is preserved or updated correctly if part of paywallProduct + } + return p; + }) + ) + ); + }; + + const handleRemovePaywallProduct = (productId: string) => { + setPaywallProducts((prevProducts) => + reorderProducts(prevProducts.filter((p) => p.productId !== productId)) + ); + }; + + const onSubmit = () => { + execute({ + paywallProducts, + paywallId: paywall.id + }); + }; + + return ( +
+
+

{paywall.name}

+ + {/* */} +
+ +
+ + + Products + + + {/* Emtpy State */} + {paywallProducts.length === 0 && ( +
+
+ This paywall does not have any products added yet. +
+
+ handleAddPaywallProduct(productId)} + products={productsWithoutAddedProducts} + /> +
+
+ )} + + + {paywallProducts.length > 0 && ( +
+ p.productId)} + strategy={verticalListSortingStrategy} + > + {paywallProducts.map((paywallProduct) => { + const product = products.find( + (p) => p.id === paywallProduct.productId + ); + if (!product) { + return null; + } + return ( + + handleRemovePaywallProduct(paywallProduct.productId) + } + onUpdate={handleUpdatePaywallProduct} + paywallProduct={paywallProduct} + product={product} + /> + ); + })} + +
+ handleAddPaywallProduct(productId)} + products={productsWithoutAddedProducts} + variant="outline" + /> +
+
+ )} +
+
+
+
+
+ ); } diff --git a/apps/web/features/paywalls/paywall-detail-product-record.tsx b/apps/web/features/paywalls/paywall-detail-product-record.tsx index eaff9b3f8..dd95f7cbc 100644 --- a/apps/web/features/paywalls/paywall-detail-product-record.tsx +++ b/apps/web/features/paywalls/paywall-detail-product-record.tsx @@ -1,227 +1,226 @@ -"use client"; +'use client'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { Product } from '@voidhash/db'; import { - DropdownMenu, - DropdownMenuTrigger, - Button, - DropdownMenuContent, - DropdownMenuItem, - Card, - CardTitle, - CardHeader, - CardContent, - FormField, - FormControl, - FormItem, - FormLabel, - FormMessage, - Input, - Form, - Switch, - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from "@voidhash/ui"; -import { EllipsisVerticalIcon, GripVerticalIcon } from "lucide-react"; - -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import type { Product } from "@voidhash/db"; -import { useEffect } from "react"; -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; + Button, + Card, + CardContent, + CardHeader, + CardTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch +} from '@voidhash/ui'; +import { EllipsisVerticalIcon, GripVerticalIcon } from 'lucide-react'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; const paywallProductSchema = z.object({ - productId: z.string().min(1, "Product ID is required"), - displayName: z - .string() - .min(2, "Display name must be at least 2 characters long"), - enableNativePurchase: z.boolean(), - enableWebCheckout: z.boolean(), - webCheckoutPaymentProviderConfigurationProductId: z.string().nullable(), + productId: z.string().min(1, 'Product ID is required'), + displayName: z + .string() + .min(2, 'Display name must be at least 2 characters long'), + enableNativePurchase: z.boolean(), + enableWebCheckout: z.boolean(), + webCheckoutPaymentProviderConfigurationProductId: z.string().nullable() }); type PaywallProductForm = z.infer; export function PaywallDetailProductRecord({ - product, - paywallProduct, - onUpdate, - onRemove, + product, + paywallProduct, + onUpdate, + onRemove }: { - product: Product; - paywallProduct: { - productId: string; - displayName: string; - enableNativePurchase: boolean; - enableWebCheckout: boolean; - webCheckoutPaymentProviderConfigurationProductId: string | null; - }; - onUpdate: (data: PaywallProductForm) => void; - onRemove: () => void; + product: Product; + paywallProduct: { + productId: string; + displayName: string; + enableNativePurchase: boolean; + enableWebCheckout: boolean; + webCheckoutPaymentProviderConfigurationProductId: string | null; + }; + onUpdate: (data: PaywallProductForm) => void; + onRemove: () => void; }) { - const form = useForm({ - resolver: zodResolver(paywallProductSchema), - defaultValues: { - productId: paywallProduct.productId, - displayName: paywallProduct.displayName, - enableNativePurchase: paywallProduct.enableNativePurchase, - enableWebCheckout: paywallProduct.enableWebCheckout, - webCheckoutPaymentProviderConfigurationProductId: - paywallProduct.webCheckoutPaymentProviderConfigurationProductId, - }, - }); + const form = useForm({ + resolver: zodResolver(paywallProductSchema), + defaultValues: { + productId: paywallProduct.productId, + displayName: paywallProduct.displayName, + enableNativePurchase: paywallProduct.enableNativePurchase, + enableWebCheckout: paywallProduct.enableWebCheckout, + webCheckoutPaymentProviderConfigurationProductId: + paywallProduct.webCheckoutPaymentProviderConfigurationProductId + } + }); - const handleOnUpdate = (data: Partial) => { - onUpdate({ - ...form.getValues(), - ...data, - }); - }; + const handleOnUpdate = (data: Partial) => { + onUpdate({ + ...form.getValues(), + ...data + }); + }; - // Makes the form "controlled" - useEffect(() => { - form.reset(paywallProduct); - }, [paywallProduct, form]); + // Makes the form "controlled" + useEffect(() => { + form.reset(paywallProduct); + }, [paywallProduct, form]); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: paywallProduct.productId }); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id: paywallProduct.productId }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 1 : undefined, // Ensure the dragged item is on top - }; + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : undefined // Ensure the dragged item is on top + }; - return ( -
-
- - -
- -
- -
-
{product.name}
-
- - - - - - - Remove - - - -
-
- - ( - - Display name - - { - handleOnUpdate({ - displayName: e.target.value, - }); - }} - /> - - - - )} - /> - - -
- ( - - - { - handleOnUpdate({ - enableNativePurchase: e, - }); - }} - /> - -

Native purchase

-
- )} - /> -
-
- -
-
- ( - - - { - handleOnUpdate({ - enableWebCheckout: e, - }); - }} - /> - -

Web checkout

-
- )} - /> -
-
- {/* */} - {/* TODO: Make this dynamic */} - -
-
-
-
-
-
- ); + return ( +
+
+ + +
+ +
+ +
+
{product.name}
+
+ + + + + + + Remove + + + +
+
+ + ( + + Display name + + { + handleOnUpdate({ + displayName: e.target.value + }); + }} + placeholder="Example: Monthly subscription" + /> + + + + )} + /> + + +
+ ( + + + { + handleOnUpdate({ + enableNativePurchase: e + }); + }} + /> + +

Native purchase

+
+ )} + /> +
+
+ +
+
+ ( + + + { + handleOnUpdate({ + enableWebCheckout: e + }); + }} + /> + +

Web checkout

+
+ )} + /> +
+
+ {/* */} + {/* TODO: Make this dynamic */} + +
+
+
+
+
+
+ ); } diff --git a/apps/web/features/paywalls/paywall-record-skeleton.tsx b/apps/web/features/paywalls/paywall-record-skeleton.tsx index 394655bf7..8da98c57d 100644 --- a/apps/web/features/paywalls/paywall-record-skeleton.tsx +++ b/apps/web/features/paywalls/paywall-record-skeleton.tsx @@ -1,20 +1,20 @@ -"use client"; +'use client'; -import { Skeleton } from "@voidhash/ui"; +import { Skeleton } from '@voidhash/ui'; export function PaywallRecordSkeleton() { - return ( -
-
-
-
- -
-
-
- -
-
-
- ); + return ( +
+
+
+
+ +
+
+
+ +
+
+
+ ); } diff --git a/apps/web/features/paywalls/paywall-record.tsx b/apps/web/features/paywalls/paywall-record.tsx index 94bb3f2a4..6b9ac6937 100644 --- a/apps/web/features/paywalls/paywall-record.tsx +++ b/apps/web/features/paywalls/paywall-record.tsx @@ -1,81 +1,81 @@ -"use client"; -import { deletePaywallAction } from "@/lib/nextjs/server-actions"; +'use client'; +import type { Paywall } from '@voidhash/db'; import { - DropdownMenu, - DropdownMenuTrigger, - Button, - DropdownMenuContent, - DropdownMenuItem, - useConfirmDialog, -} from "@voidhash/ui"; -import { useAction } from "next-safe-action/hooks"; -import { EllipsisVerticalIcon } from "lucide-react"; -import Link from "next/link"; -import { toast } from "sonner"; -import { useRouter } from "next/navigation"; -import { type Paywall } from "@voidhash/db"; + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + useConfirmDialog +} from '@voidhash/ui'; +import { EllipsisVerticalIcon } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { toast } from 'sonner'; +import { deletePaywallAction } from '@/lib/nextjs/server-actions'; export function PaywallRecord({ - paywall, - organizationSlug, - projectSlug, + paywall, + organizationSlug, + projectSlug }: { - paywall: Paywall; - organizationSlug: string; - projectSlug: string; + paywall: Paywall; + organizationSlug: string; + projectSlug: string; }) { - const router = useRouter(); - // const [setOpenEditModal] = useState(false); + const router = useRouter(); + // const [setOpenEditModal] = useState(false); - const { execute: deletePaywall, isPending } = useAction(deletePaywallAction, { - onSuccess: () => { - toast.success(`Paywall was successfully deleted`); - router.refresh(); - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to delete the paywall. Please try again.` - ); - }, - }); + const { execute: deletePaywall, isPending } = useAction(deletePaywallAction, { + onSuccess: () => { + toast.success('Paywall was successfully deleted'); + router.refresh(); + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + 'Failed to delete the paywall. Please try again.' + ); + } + }); - const { ConfirmationDialog, openDialog } = useConfirmDialog(); + const { ConfirmationDialog, openDialog } = useConfirmDialog(); - const handleDeletePaywall = async () => { - const res = await openDialog({ - title: "Delete paywall", - description: `Are you sure you want to delete this paywall?`, - }); + const handleDeletePaywall = async () => { + const res = await openDialog({ + title: 'Delete paywall', + description: 'Are you sure you want to delete this paywall?' + }); - if (!res) { - return; - } + if (!res) { + return; + } - deletePaywall({ - paywallId: paywall.id, - }); - }; + deletePaywall({ + paywallId: paywall.id + }); + }; - return ( -
- -
-
-
{paywall.name}
-
-
- - - - - - {/* + +
+
+
{paywall.name}
+
+
+ + + + + + {/* { e.preventDefault(); @@ -84,23 +84,23 @@ export function PaywallRecord({ > Edit paywall */} - - {isPending ? "Deleting..." : "Delete paywall"} - - - -
-
- - {/* + {isPending ? 'Deleting...' : 'Delete paywall'} +
+
+
+
+
+ + {/* setOpenEditModal(false)} product={product} /> */} -
- ); + + ); } diff --git a/apps/web/features/paywalls/paywalls-detail-page-skeleton.tsx b/apps/web/features/paywalls/paywalls-detail-page-skeleton.tsx index 69b728e2f..e7c2e9109 100644 --- a/apps/web/features/paywalls/paywalls-detail-page-skeleton.tsx +++ b/apps/web/features/paywalls/paywalls-detail-page-skeleton.tsx @@ -1,28 +1,28 @@ -import { Page } from "@/features/shell"; -import { SettingsCardSkeleton, Skeleton } from "@voidhash/ui"; +import { SettingsCardSkeleton, Skeleton } from '@voidhash/ui'; +import { Page } from '@/features/shell'; export function PaywallsDetailPageSkeleton() { - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-
- -
-
- -
-
-
- ); + return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+
+ +
+
+ +
+
+
+ ); } diff --git a/apps/web/features/paywalls/paywalls-detail-page.tsx b/apps/web/features/paywalls/paywalls-detail-page.tsx index d608cb5c0..0d22bc269 100644 --- a/apps/web/features/paywalls/paywalls-detail-page.tsx +++ b/apps/web/features/paywalls/paywalls-detail-page.tsx @@ -1,110 +1,108 @@ -import { Page } from "@/features/shell"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { PaywallDetailPageEditor } from "./paywall-detail-page-editor"; -import { Effect } from "effect"; -import { NotFoundError } from "@/lib/effect/errors"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { ProductService } from "@/lib/services/product.service"; -import { ProjectService } from "@/lib/services/project.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Effect } from 'effect'; +import { Page } from '@/features/shell'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { ProductService } from '@/lib/services/product.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { PaywallDetailPageEditor } from './paywall-detail-page-editor'; export async function PaywallsDetailPage({ - organizationSlug, - projectSlug, - id, + organizationSlug, + projectSlug, + id }: { - organizationSlug: string; - projectSlug: string; - id: string; + organizationSlug: string; + projectSlug: string; + id: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const paywallService = yield* PaywallService; - const productService = yield* ProductService; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }), - ); - } - const environmentService = yield* EnvironmentService; - const environment = - yield* environmentService.getEnvironmentFromCookie({ - organizationSlug, - projectSlug, - }); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - const paywall = yield* paywallService.getPaywallById(id).pipe( - Effect.catchTags({ - PaywallNotFoundError: (error) => - Effect.fail(new NotFoundError({ message: error.message })), - }), - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const paywallService = yield* PaywallService; + const productService = yield* ProductService; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + const environmentService = yield* EnvironmentService; + const environment = + yield* environmentService.getEnvironmentFromCookie({ + organizationSlug, + projectSlug + }); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + const paywall = yield* paywallService.getPaywallById(id).pipe( + Effect.catchTags({ + PaywallNotFoundError: (error) => + Effect.fail(new NotFoundError({ message: error.message })) + }) + ); - const paywallProducts = yield* paywallService - .getPaywallProducts(id) - .pipe( - Effect.catchTags({ - PaywallNotFoundError: (error) => - Effect.fail( - new NotFoundError({ message: error.message }), - ), - }), - ); - const products = yield* productService.getProducts(project.id); - return { project, paywall, paywallProducts, products }; - }), - ); - }), - ); - }), - ); + const paywallProducts = yield* paywallService + .getPaywallProducts(id) + .pipe( + Effect.catchTags({ + PaywallNotFoundError: (error) => + Effect.fail(new NotFoundError({ message: error.message })) + }) + ); + const products = yield* productService.getProducts(project.id); + return { project, paywall, paywallProducts, products }; + }) + ); + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { paywall, paywallProducts, products } = data.value; + const { paywall, paywallProducts, products } = data.value; - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
- -
-
- ); + return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+ +
+
+ ); } diff --git a/apps/web/features/paywalls/paywalls-page-empty-state.tsx b/apps/web/features/paywalls/paywalls-page-empty-state.tsx index fcda92484..598b3903e 100644 --- a/apps/web/features/paywalls/paywalls-page-empty-state.tsx +++ b/apps/web/features/paywalls/paywalls-page-empty-state.tsx @@ -1,41 +1,41 @@ -"use client"; +'use client'; import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Button, -} from "@voidhash/ui"; -import { useState } from "react"; -import { CreatePaywallModal } from "./create-paywall-modal"; + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@voidhash/ui'; +import { useState } from 'react'; +import { CreatePaywallModal } from './create-paywall-modal'; export function PaywallsPageEmptyState({ projectId }: { projectId: string }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); - return ( - - - No paywalls yet - - Paywalls are screens displayed to your customers. Each paywall can - have a different set of products, offers, and additional - configurations that enable you to optimize your checkout experience - remotely. - - - - setOpen(false)} - trigger={ - - } - projectId={projectId} - onSuccess={() => setOpen(false)} - /> - - - ); + return ( + + + No paywalls yet + + Paywalls are screens displayed to your customers. Each paywall can + have a different set of products, offers, and additional + configurations that enable you to optimize your checkout experience + remotely. + + + + setOpen(false)} + onSuccess={() => setOpen(false)} + open={open} + projectId={projectId} + trigger={ + + } + /> + + + ); } diff --git a/apps/web/features/paywalls/paywalls-page-skeleton.tsx b/apps/web/features/paywalls/paywalls-page-skeleton.tsx index 3886c39ed..ac70e8130 100644 --- a/apps/web/features/paywalls/paywalls-page-skeleton.tsx +++ b/apps/web/features/paywalls/paywalls-page-skeleton.tsx @@ -1,24 +1,25 @@ -import { Page } from "@/features/shell"; -import { Card } from "@voidhash/ui"; -import { PaywallRecordSkeleton } from "./paywall-record-skeleton"; +import { Card } from '@voidhash/ui'; +import { Page } from '@/features/shell'; +import { PaywallRecordSkeleton } from './paywall-record-skeleton'; export function PaywallsPageSkeleton() { - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-
-

Paywalls

-
+ return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+
+

Paywalls

+
-
- - {Array.from({ length: 3 }).map((_, index) => ( - - ))} - -
-
-
- ); +
+ + {Array.from({ length: 3 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton + + ))} + +
+
+
+ ); } diff --git a/apps/web/features/paywalls/paywalls-page.tsx b/apps/web/features/paywalls/paywalls-page.tsx index b60d6858f..60deb2f31 100644 --- a/apps/web/features/paywalls/paywalls-page.tsx +++ b/apps/web/features/paywalls/paywalls-page.tsx @@ -1,99 +1,99 @@ -import { Page } from "@/features/shell"; -import { Card } from "@voidhash/ui"; -import { PaywallRecord } from "./paywall-record"; -import { PaywallsPageEmptyState } from "./paywalls-page-empty-state"; -import { CreatePaywallModalButton } from "./create-paywall-modal-button"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { ProjectService } from "@/lib/services/project.service"; -import { Effect } from "effect"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { NotFoundError } from "@/lib/effect/errors"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Card } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { Page } from '@/features/shell'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { CreatePaywallModalButton } from './create-paywall-modal-button'; +import { PaywallRecord } from './paywall-record'; +import { PaywallsPageEmptyState } from './paywalls-page-empty-state'; export async function PaywallsPage({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string; - projectSlug: string; + organizationSlug: string; + projectSlug: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const paywallService = yield* PaywallService; - const environmentService = yield* EnvironmentService; - const environment = - yield* environmentService.getEnvironmentFromCookie({ - organizationSlug, - projectSlug, - }); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - const paywalls = yield* paywallService.getPaywalls(project.id); - return { project, paywalls }; - }) - ); - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const paywallService = yield* PaywallService; + const environmentService = yield* EnvironmentService; + const environment = + yield* environmentService.getEnvironmentFromCookie({ + organizationSlug, + projectSlug + }); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + const paywalls = yield* paywallService.getPaywalls(project.id); + return { project, paywalls }; + }) + ); + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { project, paywalls } = data.value; + const { project, paywalls } = data.value; - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-
-

Paywalls

- {paywalls.length > 0 && ( - - )} -
+ return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+
+

Paywalls

+ {paywalls.length > 0 && ( + + )} +
-
- {paywalls.length === 0 ? ( - - ) : ( - - {paywalls.map((paywall) => ( - - ))} - - )} -
-
-
- ); +
+ {paywalls.length === 0 ? ( + + ) : ( + + {paywalls.map((paywall) => ( + + ))} + + )} +
+
+
+ ); } diff --git a/apps/web/features/perks/create-perk-modal-button.tsx b/apps/web/features/perks/create-perk-modal-button.tsx index 3b9ba1941..25ac98943 100644 --- a/apps/web/features/perks/create-perk-modal-button.tsx +++ b/apps/web/features/perks/create-perk-modal-button.tsx @@ -1,20 +1,18 @@ -"use client"; -import { useState } from "react"; -import { Button } from "@voidhash/ui/button"; -import { CreatePerkModal } from "./create-perk-modal"; +'use client'; +import { Button } from '@voidhash/ui/button'; +import { useState } from 'react'; +import { CreatePerkModal } from './create-perk-modal'; export function CreatePerkModalButton({ projectId }: { projectId: string }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); - return ( - <> - setOpen(false)} - trigger={} - projectId={projectId} - onSuccess={() => setOpen(false)} - /> - - ); + return ( + setOpen(false)} + onSuccess={() => setOpen(false)} + open={open} + projectId={projectId} + trigger={} + /> + ); } diff --git a/apps/web/features/perks/create-perk-modal.tsx b/apps/web/features/perks/create-perk-modal.tsx index 695817df4..1a16511e7 100644 --- a/apps/web/features/perks/create-perk-modal.tsx +++ b/apps/web/features/perks/create-perk-modal.tsx @@ -1,163 +1,163 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui/button"; +import { zodResolver } from '@hookform/resolvers/zod'; +import { InfoTooltip } from '@voidhash/ui'; +import { Button } from '@voidhash/ui/button'; import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@voidhash/ui/dialog'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; -import { useAction } from "next-safe-action/hooks"; -import { createPerkAction } from "@/lib/nextjs/server-actions"; -import { useRouter } from "next/navigation"; -import { InferSafeActionFnResult } from "next-safe-action"; -import { InfoTooltip } from "@voidhash/ui"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { useRouter } from 'next/navigation'; +import type { InferSafeActionFnResult } from 'next-safe-action'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { createPerkAction } from '@/lib/nextjs/server-actions'; const createPerkSchema = z.object({ - name: z - .string() - .min(3, "Name must be at least 3 characters long") - .max(32, "Name must be less than 32 characters"), - slug: z - .string() - .min(3, "Slug must be at least 3 characters long") - .max(32, "Slug must be less than 32 characters") - .regex( - /^[a-z0-9_-]+$/, - "Slug must contain only lowercase letters, numbers, underscores, and hyphens" - ), + name: z + .string() + .min(3, 'Name must be at least 3 characters long') + .max(32, 'Name must be less than 32 characters'), + slug: z + .string() + .min(3, 'Slug must be at least 3 characters long') + .max(32, 'Slug must be less than 32 characters') + .regex( + /^[a-z0-9_-]+$/, + 'Slug must contain only lowercase letters, numbers, underscores, and hyphens' + ) }); type CreatePerkForm = z.infer; -type Perk = InferSafeActionFnResult["data"]; +type Perk = InferSafeActionFnResult['data']; interface CreatePerkModalProps { - open: boolean; - onClose: () => void; - trigger: React.ReactNode; - projectId: string; - onSuccess?: (perk: Perk) => void; + open: boolean; + onClose: () => void; + trigger: React.ReactNode; + projectId: string; + onSuccess?: (perk: Perk) => void; } export function CreatePerkModal({ - open, - onClose, - trigger, - projectId, - onSuccess, + open, + onClose, + trigger, + projectId, + onSuccess }: CreatePerkModalProps) { - const router = useRouter(); - const form = useForm({ - resolver: zodResolver(createPerkSchema), - defaultValues: { - name: "", - slug: "", - }, - }); + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(createPerkSchema), + defaultValues: { + name: '', + slug: '' + } + }); - const { execute, isPending } = useAction(createPerkAction, { - onSuccess: (res) => { - if (res.data) { - toast.success("Perk created successfully"); - onSuccess?.(res.data); - router.refresh(); - handleOpenChange(false); - } - }, - onError: (error) => { - toast.error(error.error.serverError || "Failed to create perk"); - }, - }); + const { execute, isPending } = useAction(createPerkAction, { + onSuccess: (res) => { + if (res.data) { + toast.success('Perk created successfully'); + onSuccess?.(res.data); + router.refresh(); + handleOpenChange(false); + } + }, + onError: (error) => { + toast.error(error.error.serverError || 'Failed to create perk'); + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - form.reset(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + form.reset(); + } + }; - const onSubmit = (data: CreatePerkForm) => { - execute({ ...data, projectId }); - }; + const onSubmit = (data: CreatePerkForm) => { + execute({ ...data, projectId }); + }; - return ( - - {trigger} - - - Create Perk - -
- - ( - - Name - - - - - - )} - /> - ( - - - Slug (ID) - - - - - - - - )} - /> - - - - - -
-
- ); + return ( + + {trigger} + + + Create Perk + +
+ + ( + + Name + + + + + + )} + /> + ( + + + Slug (ID) + + + + + + + + )} + /> + + + + + +
+
+ ); } diff --git a/apps/web/features/perks/perk-record-skeleton.tsx b/apps/web/features/perks/perk-record-skeleton.tsx index 4cf7743fd..626513009 100644 --- a/apps/web/features/perks/perk-record-skeleton.tsx +++ b/apps/web/features/perks/perk-record-skeleton.tsx @@ -1,20 +1,20 @@ -"use client"; +'use client'; -import { Skeleton } from "@voidhash/ui"; +import { Skeleton } from '@voidhash/ui'; export function PerkRecordSkeleton() { - return ( -
-
-
-
- -
-
-
- -
-
-
- ); + return ( +
+
+
+
+ +
+
+
+ +
+
+
+ ); } diff --git a/apps/web/features/perks/perk-record.tsx b/apps/web/features/perks/perk-record.tsx index 1f7104b2b..097db804d 100644 --- a/apps/web/features/perks/perk-record.tsx +++ b/apps/web/features/perks/perk-record.tsx @@ -1,110 +1,106 @@ -"use client"; -import { deletePerkAction } from "@/lib/nextjs/server-actions"; +'use client'; +import type { Perk } from '@voidhash/db'; import { - DropdownMenu, - DropdownMenuTrigger, - Button, - DropdownMenuContent, - DropdownMenuItem, - useConfirmDialog, - TooltipProvider, - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@voidhash/ui"; -import { useAction } from "next-safe-action/hooks"; -import { CopyIcon, EllipsisVerticalIcon } from "lucide-react"; -import { toast } from "sonner"; -import { useRouter } from "next/navigation"; -import type { Perk } from "@voidhash/db"; + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + useConfirmDialog +} from '@voidhash/ui'; +import { CopyIcon, EllipsisVerticalIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { toast } from 'sonner'; +import { deletePerkAction } from '@/lib/nextjs/server-actions'; // import { EditProductModal } from "./edit-product-modal"; -export function PerkRecord({ - perk, -}: { - perk: Perk; -}) { - const router = useRouter(); - // const [setOpenEditModal] = useState(false); +export function PerkRecord({ perk }: { perk: Perk }) { + const router = useRouter(); + // const [setOpenEditModal] = useState(false); - const { execute: deletePerk, isPending } = useAction(deletePerkAction, { - onExecute: () => { - toast.loading("Deleting perk..."); - }, - onSuccess: () => { - toast.dismiss(); - toast.success(`Perk was successfully deleted`); - router.refresh(); - }, - onError: (error) => { - toast.dismiss(); - toast.error( - error.error.serverError ?? - `Failed to delete the perk. Please try again.` - ); - }, - }); + const { execute: deletePerk, isPending } = useAction(deletePerkAction, { + onExecute: () => { + toast.loading('Deleting perk...'); + }, + onSuccess: () => { + toast.dismiss(); + toast.success('Perk was successfully deleted'); + router.refresh(); + }, + onError: (error) => { + toast.dismiss(); + toast.error( + error.error.serverError ?? + 'Failed to delete the perk. Please try again.' + ); + } + }); - const { ConfirmationDialog, openDialog } = useConfirmDialog(); + const { ConfirmationDialog, openDialog } = useConfirmDialog(); - const handleDeletePerk = async () => { - const res = await openDialog({ - title: "Delete perk", - description: `Are you sure you want to delete this perk?`, - }); + const handleDeletePerk = async () => { + const res = await openDialog({ + title: 'Delete perk', + description: 'Are you sure you want to delete this perk?' + }); - if (!res) { - return; - } + if (!res) { + return; + } - deletePerk({ - perkId: perk.id, - }); - }; + deletePerk({ + perkId: perk.id + }); + }; - return ( -
- {/* + {/* */} -
-
-
-
{perk.name}
- {perk.slug} -
-
-
- - - - - - -

Click to copy Slug (ID)

-
-
-
- - - - - - {/* +
+
+
{perk.name}
+ {perk.slug} +
+
+
+ + + + + + +

Click to copy Slug (ID)

+
+
+
+ + + + + + {/* { e.preventDefault(); @@ -114,23 +110,23 @@ export function PerkRecord({ Edit perk */} - - {isPending ? "Deleting..." : "Delete perk"} - - - -
-
- - {/* + {isPending ? 'Deleting...' : 'Delete perk'} + + + +
+
+ + {/* setOpenEditModal(false)} product={product} /> */} - - ); + + ); } diff --git a/apps/web/features/perks/perks-page-empty-state.tsx b/apps/web/features/perks/perks-page-empty-state.tsx index 6486f13bb..0e20e599f 100644 --- a/apps/web/features/perks/perks-page-empty-state.tsx +++ b/apps/web/features/perks/perks-page-empty-state.tsx @@ -1,39 +1,39 @@ -"use client"; +'use client'; import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Button, -} from "@voidhash/ui"; -import { useState } from "react"; -import { CreatePerkModal } from "./create-perk-modal"; + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@voidhash/ui'; +import { useState } from 'react'; +import { CreatePerkModal } from './create-perk-modal'; export function PerksPageEmptyState({ projectId }: { projectId: string }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); - return ( - - - No perks yet - - Each product may unlock one or more perks for the customer, which your - app uses to grant access to various features. Examples of perks - include "Full-Access", "AI-features", and - "Premium-recipes". - - - - setOpen(false)} - trigger={} - projectId={projectId} - onSuccess={() => setOpen(false)} - /> - - - ); + return ( + + + No perks yet + + Each product may unlock one or more perks for the customer, which your + app uses to grant access to various features. Examples of perks + include "Full-Access", "AI-features", and + "Premium-recipes". + + + + setOpen(false)} + onSuccess={() => setOpen(false)} + open={open} + projectId={projectId} + trigger={} + /> + + + ); } diff --git a/apps/web/features/perks/perks-page-skeleton.tsx b/apps/web/features/perks/perks-page-skeleton.tsx index 777d5b8e7..919209727 100644 --- a/apps/web/features/perks/perks-page-skeleton.tsx +++ b/apps/web/features/perks/perks-page-skeleton.tsx @@ -1,25 +1,26 @@ -import { Card } from "@voidhash/ui"; -import { PerkRecordSkeleton } from "./perk-record-skeleton"; +import { Card } from '@voidhash/ui'; +import { PerkRecordSkeleton } from './perk-record-skeleton'; export function PerksPageSkeleton() { - return ( -
-
-
-

Perks

-

- List of unlockable features / perks. -

-
-
+ return ( +
+
+
+

Perks

+

+ List of unlockable features / perks. +

+
+
-
- - {Array.from({ length: 3 }).map((_, index) => ( - - ))} - -
-
- ); +
+ + {Array.from({ length: 3 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton + + ))} + +
+
+ ); } diff --git a/apps/web/features/perks/perks-page.tsx b/apps/web/features/perks/perks-page.tsx index 5e9eda230..2745eea77 100644 --- a/apps/web/features/perks/perks-page.tsx +++ b/apps/web/features/perks/perks-page.tsx @@ -1,93 +1,93 @@ -import { Card } from "@voidhash/ui"; -import { PerkRecord } from "./perk-record"; -import { PerksPageEmptyState } from "./perks-page-empty-state"; -import { CreatePerkModalButton } from "./create-perk-modal-button"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { Effect } from "effect"; -import { PerkService } from "@/lib/services/perk.service"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { ProjectService } from "@/lib/services/project.service"; -import { NotFoundError } from "@/lib/effect/errors"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Card } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { PerkService } from '@/lib/services/perk.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { CreatePerkModalButton } from './create-perk-modal-button'; +import { PerkRecord } from './perk-record'; +import { PerksPageEmptyState } from './perks-page-empty-state'; export async function PerksPage({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string; - projectSlug: string; + organizationSlug: string; + projectSlug: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - organizationSlug, - projectSlug, - }); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const perkService = yield* PerkService; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - const perks = yield* perkService.getPerks(project.id); - return { project, perks }; - }) - ); - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + organizationSlug, + projectSlug + }); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const perkService = yield* PerkService; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + const perks = yield* perkService.getPerks(project.id); + return { project, perks }; + }) + ); + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { project, perks } = data.value; + const { project, perks } = data.value; - return ( -
-
-
-

Perks

-

- List of unlockable features / perks. -

-
- {perks.length > 0 && } -
+ return ( +
+
+
+

Perks

+

+ List of unlockable features / perks. +

+
+ {perks.length > 0 && } +
-
- {perks.length === 0 ? ( - - ) : ( - - {perks.map((perk) => ( - - ))} - - )} -
-
- ); +
+ {perks.length === 0 ? ( + + ) : ( + + {perks.map((perk) => ( + + ))} + + )} +
+
+ ); } diff --git a/apps/web/features/products/create-product-modal-button.tsx b/apps/web/features/products/create-product-modal-button.tsx index 454f3e4d8..37231933d 100644 --- a/apps/web/features/products/create-product-modal-button.tsx +++ b/apps/web/features/products/create-product-modal-button.tsx @@ -1,20 +1,18 @@ -"use client"; -import { useState } from "react"; -import { Button } from "@voidhash/ui/button"; -import { CreateProductModal } from "./create-product-modal"; +'use client'; +import { Button } from '@voidhash/ui/button'; +import { useState } from 'react'; +import { CreateProductModal } from './create-product-modal'; export function CreateProductModalButton({ projectId }: { projectId: string }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); - return ( - <> - setOpen(false)} - trigger={} - projectId={projectId} - onSuccess={() => setOpen(false)} - /> - - ); + return ( + setOpen(false)} + onSuccess={() => setOpen(false)} + open={open} + projectId={projectId} + trigger={} + /> + ); } diff --git a/apps/web/features/products/create-product-modal.tsx b/apps/web/features/products/create-product-modal.tsx index a139a682f..4b1740443 100644 --- a/apps/web/features/products/create-product-modal.tsx +++ b/apps/web/features/products/create-product-modal.tsx @@ -1,210 +1,208 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui/button"; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ProductType, ProductTypeLabels } from '@voidhash/lib/index'; +import { Badge, InfoTooltip, RadioGroup, RadioGroupItem } from '@voidhash/ui'; +import { Button } from '@voidhash/ui/button'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@voidhash/ui/dialog'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; -import { useAction } from "next-safe-action/hooks"; -import { createProductAction } from "@/lib/nextjs/server-actions"; -import { useRouter } from "next/navigation"; -import { Badge, InfoTooltip, RadioGroup, RadioGroupItem } from "@voidhash/ui"; -import { ProductTypeLabels, ProductType } from "@voidhash/lib/index"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { createProductAction } from '@/lib/nextjs/server-actions'; const createProductSchema = z.object({ - name: z - .string() - .min(3, "Name must be at least 3 characters long") - .max(32, "Name must be less than 32 characters"), + name: z + .string() + .min(3, 'Name must be at least 3 characters long') + .max(32, 'Name must be less than 32 characters'), - type: z.nativeEnum(ProductType), + type: z.nativeEnum(ProductType) }); type CreateProductForm = z.infer; // Define a Product type matching the DB schema export type Product = { - id: string; - name: string; - projectId: string; - createdAt?: string; - updatedAt?: string; + id: string; + name: string; + projectId: string; + createdAt?: string; + updatedAt?: string; }; interface CreateProductModalProps { - open: boolean; - onClose: () => void; - trigger: React.ReactNode; - projectId: string; - onSuccess?: (product: { - id: string; - }) => void; + open: boolean; + onClose: () => void; + trigger: React.ReactNode; + projectId: string; + onSuccess?: (product: { id: string }) => void; } export function CreateProductModal({ - open, - onClose, - trigger, - projectId, - onSuccess, + open, + onClose, + trigger, + projectId, + onSuccess }: CreateProductModalProps) { - const router = useRouter(); - const form = useForm({ - resolver: zodResolver(createProductSchema), - defaultValues: { - name: "", - type: ProductType.Subscription, - }, - }); + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(createProductSchema), + defaultValues: { + name: '', + type: ProductType.Subscription + } + }); - const { execute, isPending } = useAction(createProductAction, { - onSuccess: (res) => { - if (res.data) { - toast.success("Product created successfully"); - onSuccess?.(res.data); - router.refresh(); - handleOpenChange(false); - } - }, - onError: (error) => { - toast.error(error.error.serverError || "Failed to create product"); - }, - }); + const { execute, isPending } = useAction(createProductAction, { + onSuccess: (res) => { + if (res.data) { + toast.success('Product created successfully'); + onSuccess?.(res.data); + router.refresh(); + handleOpenChange(false); + } + }, + onError: (error) => { + toast.error(error.error.serverError || 'Failed to create product'); + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - form.reset(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + form.reset(); + } + }; - const onSubmit = (data: CreateProductForm) => { - execute({ ...data, projectId }); - }; + const onSubmit = (data: CreateProductForm) => { + execute({ ...data, projectId }); + }; - return ( - - {trigger} - - - Create New Product - - Create a new product for your project. - - -
- - ( - - Product Name - - - - - - )} - /> - ( - - Product type - - - - - - - - - {ProductTypeLabels[ProductType.Subscription]} - - - - - - - - - - - {ProductTypeLabels[ProductType.OneTime]} - - Coming Soon - - - - - - - - - - - - {ProductTypeLabels[ProductType.OneTimeConsumable]} - - Coming Soon - - - - - - - - - )} - /> + return ( + + {trigger} + + + Create New Product + + Create a new product for your project. + + + + + ( + + Product Name + + + + + + )} + /> + ( + + Product type + + + + + + + + + {ProductTypeLabels[ProductType.Subscription]} + + + + + + + + + + + {ProductTypeLabels[ProductType.OneTime]} + + Coming Soon + + + + + + + + + + + + {ProductTypeLabels[ProductType.OneTimeConsumable]} + + Coming Soon + + + + + + + + + )} + /> - - - - - - - - ); + + + + + +
+
+ ); } diff --git a/apps/web/features/products/edit-product-modal.tsx b/apps/web/features/products/edit-product-modal.tsx index 39561eb03..4d0e33c99 100644 --- a/apps/web/features/products/edit-product-modal.tsx +++ b/apps/web/features/products/edit-product-modal.tsx @@ -1,128 +1,127 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui/button"; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { Product } from '@voidhash/db'; +import { Button } from '@voidhash/ui/button'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@voidhash/ui/dialog'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; -import { useAction } from "next-safe-action/hooks"; -import { updateProductAction } from "@/lib/nextjs/server-actions"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import type { Product } from "@voidhash/db"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { updateProductAction } from '@/lib/nextjs/server-actions'; const updateProductSchema = z.object({ - name: z.string().min(1), + name: z.string().min(1) }); type UpdateProductForm = z.infer; // Define a Product type matching the DB schema interface EditProductModalProps { - open: boolean; - onClose: () => void; - product: Product; + open: boolean; + onClose: () => void; + product: Product; } export function EditProductModal({ - open, - onClose, - product, + open, + onClose, + product }: EditProductModalProps) { - const router = useRouter(); - const form = useForm({ - resolver: zodResolver(updateProductSchema), - defaultValues: { - name: "", - }, - }); + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(updateProductSchema), + defaultValues: { + name: '' + } + }); - const { execute, isPending } = useAction(updateProductAction, { - onSuccess: () => { - toast.success("Product updated successfully"); - router.refresh(); - onClose?.(); - handleOpenChange(false); - }, - onError: (error) => { - toast.error(error.error.serverError || "Failed to update the product"); - }, - }); + const { execute, isPending } = useAction(updateProductAction, { + onSuccess: () => { + toast.success('Product updated successfully'); + router.refresh(); + onClose?.(); + handleOpenChange(false); + }, + onError: (error) => { + toast.error(error.error.serverError || 'Failed to update the product'); + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - form.reset(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + form.reset(); + } + }; - const onSubmit = (data: UpdateProductForm) => { - execute({ ...data, productId: product.id }); - }; + const onSubmit = (data: UpdateProductForm) => { + execute({ ...data, productId: product.id }); + }; - useEffect(() => { - if (open) { - form.reset({ - name: product.name, - }); - } - }, [open]); + useEffect(() => { + if (!open) { + form.reset({ + name: product.name + }); + } + }, [open, form, product.name]); - return ( - - - - Edit Product - Edit the product details. - -
- - ( - - Product Name - - - - - - )} - /> - - - - - -
-
- ); + return ( + + + + Edit Product + Edit the product details. + +
+ + ( + + Product Name + + + + + + )} + /> + + + + + +
+
+ ); } diff --git a/apps/web/features/products/product-detail-add-perk-button.tsx b/apps/web/features/products/product-detail-add-perk-button.tsx index bf3c66e0c..3ca556056 100644 --- a/apps/web/features/products/product-detail-add-perk-button.tsx +++ b/apps/web/features/products/product-detail-add-perk-button.tsx @@ -1,99 +1,100 @@ -"use client"; +'use client'; +import type { Perk } from '@voidhash/db'; import { - Button, - cn, - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - Popover, - PopoverContent, - PopoverTrigger, -} from "@voidhash/ui"; -import { useState } from "react"; -import { createProductPerkAction } from "@/lib/nextjs/server-actions"; -import { Check } from "lucide-react"; -import { useAction } from "next-safe-action/hooks"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; -import type { Perk } from "@voidhash/db"; + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + cn, + Popover, + PopoverContent, + PopoverTrigger +} from '@voidhash/ui'; +import { Check } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { createProductPerkAction } from '@/lib/nextjs/server-actions'; export function ProductDetailAddPerkButton({ - productId, - perks, - variant = "default", + productId, + perks, + variant = 'default' }: { - productId: string; - perks: Perk[]; - variant?: "default" | "secondary"; + productId: string; + perks: Perk[]; + variant?: 'default' | 'secondary'; }) { - const [open, setOpen] = useState(false); - const [value, setValue] = useState(""); - const router = useRouter(); + const [open, setOpen] = useState(false); + const [value, setValue] = useState(''); + const router = useRouter(); - const { execute } = useAction(createProductPerkAction, { - onExecute: () => { - toast.loading("Adding perk..."); - }, - onSuccess: () => { - toast.dismiss(); - toast.success("Perk added"); - router.refresh(); - }, - onError: (error) => { - toast.dismiss(); - toast.error(error.error.serverError ?? "An error occurred"); - }, - }); + const { execute } = useAction(createProductPerkAction, { + onExecute: () => { + toast.loading('Adding perk...'); + }, + onSuccess: () => { + toast.dismiss(); + toast.success('Perk added'); + router.refresh(); + }, + onError: (error) => { + toast.dismiss(); + toast.error(error.error.serverError ?? 'An error occurred'); + } + }); - const handleSelect = (perkId: string) => { - execute({ - productId, - perkId, - }); - setValue(perkId); - setOpen(false); - }; - return ( - - - - - - - - - No perks found. - - {perks.map((perk) => ( - { - handleSelect(perk.id); - setValue(""); - setOpen(false); - }} - > - - {perk.name} - - ))} - - - - - - ); + const handleSelect = (perkId: string) => { + execute({ + productId, + perkId + }); + setValue(perkId); + setOpen(false); + }; + return ( + + + {/** biome-ignore lint/a11y/useSemanticElements: custom component */} + + + + + + + No perks found. + + {perks.map((perk) => ( + { + handleSelect(perk.id); + setValue(''); + setOpen(false); + }} + value={perk.id} + > + + {perk.name} + + ))} + + + + + + ); } diff --git a/apps/web/features/products/product-detail-add-product-button.tsx b/apps/web/features/products/product-detail-add-product-button.tsx index 587104381..e008823ee 100644 --- a/apps/web/features/products/product-detail-add-product-button.tsx +++ b/apps/web/features/products/product-detail-add-product-button.tsx @@ -1,36 +1,36 @@ -"use client"; +'use client'; -import { Button } from "@voidhash/ui"; -import { ProviderProductSheet } from "./provider-product-sheet"; -import { useState } from "react"; +import { Button } from '@voidhash/ui'; +import { useState } from 'react'; +import { ProviderProductSheet } from './provider-product-sheet'; export function ProductDetailAddProductButton({ - productId, - providerId, - paymentProviderConfigurationId, - title, - variant = "default", + productId, + providerId, + paymentProviderConfigurationId, + title, + variant = 'default' }: { - productId: string; - paymentProviderConfigurationId: string; - providerId: string; - title: string; - variant?: "default" | "secondary"; + productId: string; + paymentProviderConfigurationId: string; + providerId: string; + title: string; + variant?: 'default' | 'secondary'; }) { - const [open, setOpen] = useState(false); - return ( - <> - - setOpen(false)} - productId={productId} - paymentProviderConfigurationId={paymentProviderConfigurationId} - providerId={providerId} - mode={"add"} - /> - - ); + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} + open={open} + paymentProviderConfigurationId={paymentProviderConfigurationId} + productId={productId} + providerId={providerId} + /> + + ); } diff --git a/apps/web/features/products/product-detail-page-skeleton.tsx b/apps/web/features/products/product-detail-page-skeleton.tsx index 9cc7ac150..64c8df3c2 100644 --- a/apps/web/features/products/product-detail-page-skeleton.tsx +++ b/apps/web/features/products/product-detail-page-skeleton.tsx @@ -1,30 +1,31 @@ -import { Page } from "@/features/shell"; -import { SettingsCardSkeleton, Skeleton } from "@voidhash/ui"; +import { SettingsCardSkeleton, Skeleton } from '@voidhash/ui'; +import { Page } from '@/features/shell'; export function ProductsDetailPageSkeleton() { - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-
- -
-
- {Array.from({ length: 2 }).map((_, index) => ( - - ))} -
-
-
- ); + return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+
+ +
+
+ {Array.from({ length: 2 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton + + ))} +
+
+
+ ); } diff --git a/apps/web/features/products/product-detail-page.tsx b/apps/web/features/products/product-detail-page.tsx index b11c962d1..f67b2b0ca 100644 --- a/apps/web/features/products/product-detail-page.tsx +++ b/apps/web/features/products/product-detail-page.tsx @@ -1,322 +1,325 @@ -import { Page } from "@/features/shell"; -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { ProductDetailPaymentProvidersEmptyState } from "./product-detail-payment-providers-empty-state"; +import { Environment as EnvironmentEnum } from '@voidhash/lib/index'; import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { PaymentProviderLogo } from "../projects/settings/payment-providers/payment-provider-logo"; -import { ProductDetailAddProductButton } from "./product-detail-add-product-button"; -import { ProductDetailProviderProductRecord } from "./product-detail-provider-product-record"; -import { ProductDetailPerksEmptyState } from "./product-detail-perks-empty-state"; -import { ProductDetailPerkRecord } from "./product-detail-product-perk-record"; -import { ProductDetailAddPerkButton } from "./product-detail-add-perk-button"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { PerkService } from "@/lib/services/perk.service"; -import { Effect } from "effect"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { ProductService } from "@/lib/services/product.service"; -import { PaymentProviderService } from "@/lib/services/payment-provider.service"; + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle +} from '@voidhash/ui'; +import { Effect } from 'effect'; +import { Page } from '@/features/shell'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; -import { ProjectService } from "@/lib/services/project.service"; -import { NotFoundError } from "@/lib/effect/errors"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/index"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { PaymentProviderService } from '@/lib/services/payment-provider.service'; +import { PerkService } from '@/lib/services/perk.service'; +import { ProductService } from '@/lib/services/product.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { PaymentProviderLogo } from '../projects/settings/payment-providers/payment-provider-logo'; +import { ProductDetailAddPerkButton } from './product-detail-add-perk-button'; +import { ProductDetailAddProductButton } from './product-detail-add-product-button'; +import { ProductDetailPaymentProvidersEmptyState } from './product-detail-payment-providers-empty-state'; +import { ProductDetailPerksEmptyState } from './product-detail-perks-empty-state'; +import { ProductDetailPerkRecord } from './product-detail-product-perk-record'; +import { ProductDetailProviderProductRecord } from './product-detail-provider-product-record'; export async function ProductDetailPage({ - organizationSlug, - projectSlug, - id, + organizationSlug, + projectSlug, + id }: { - organizationSlug: string; - projectSlug: string; - id: string; + organizationSlug: string; + projectSlug: string; + id: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environmentService = yield* EnvironmentService; - const environment = - yield* environmentService.getEnvironmentFromCookie({ - organizationSlug, - projectSlug, - }); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - const productService = yield* ProductService; - const paymentProviderService = yield* PaymentProviderService; - const perkService = yield* PerkService; - const environment = yield* Environment; - const projectService = yield* ProjectService; + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environmentService = yield* EnvironmentService; + const environment = + yield* environmentService.getEnvironmentFromCookie({ + organizationSlug, + projectSlug + }); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + const productService = yield* ProductService; + const paymentProviderService = yield* PaymentProviderService; + const perkService = yield* PerkService; + const environment = yield* Environment; + const projectService = yield* ProjectService; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } - const [ - product, - providerProducts, - paymentProviderConfigurations, - perks, - productPerks, - ] = yield* Effect.all([ - productService.getProductById(id), - productService.getProviderProductsByProductId(id), - paymentProviderService.getPaymentProviderConfigurations( - project.id - ), - perkService.getPerks(project.id), - productService.getProductPerksByProductId(id), - ], { - concurrency: "unbounded" - }); + const [ + product, + providerProducts, + paymentProviderConfigurations, + perks, + productPerks + ] = yield* Effect.all( + [ + productService.getProductById(id), + productService.getProviderProductsByProductId(id), + paymentProviderService.getPaymentProviderConfigurations( + project.id + ), + perkService.getPerks(project.id), + productService.getProductPerksByProductId(id) + ], + { + concurrency: 'unbounded' + } + ); - return { - product, - providerProducts, - paymentProviderConfigurations, - environment, - perks, - productPerks, - }; - }) - ); - }) - ); - }) - ); + return { + product, + providerProducts, + paymentProviderConfigurations, + environment, + perks, + productPerks + }; + }) + ); + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { - product, - providerProducts, - paymentProviderConfigurations, - environment, - perks, - productPerks, - } = data.value; + const { + product, + providerProducts, + paymentProviderConfigurations, + environment, + perks, + productPerks + } = data.value; - const enabledPaymentProviderConfigurations = paymentProviderConfigurations - .map((paymentProviderConfiguration) => { - const paymentProvider = paymentProviders.find( - (paymentProvider) => - paymentProvider.getId() === paymentProviderConfiguration.providerId - ); + const enabledPaymentProviderConfigurations = paymentProviderConfigurations + .map((paymentProviderConfiguration) => { + const paymentProvider = paymentProviders.find( + (paymentProvider) => + paymentProvider.getId() === paymentProviderConfiguration.providerId + ); - if (!paymentProvider) { - return null; - } + if (!paymentProvider) { + return null; + } - return { - paymentProvider, - id: paymentProviderConfiguration.id, - name: paymentProviderConfiguration.name, - enabled: - !!paymentProviderConfiguration && - paymentProviderConfiguration.enabled, - configuration: paymentProviderConfiguration, - }; - }) - .filter( - (paymentProviderConfiguration) => paymentProviderConfiguration !== null - ) - .filter( - (paymentProviderConfiguration) => - paymentProviderConfiguration.paymentProvider.getIsProductConfigurable() && - paymentProviderConfiguration.enabled - ); + return { + paymentProvider, + id: paymentProviderConfiguration.id, + name: paymentProviderConfiguration.name, + enabled: + !!paymentProviderConfiguration && + paymentProviderConfiguration.enabled, + configuration: paymentProviderConfiguration + }; + }) + .filter( + (paymentProviderConfiguration) => paymentProviderConfiguration !== null + ) + .filter( + (paymentProviderConfiguration) => + paymentProviderConfiguration.paymentProvider.getIsProductConfigurable() && + paymentProviderConfiguration.enabled + ); - const perksWithoutProductPerks = perks.filter( - (perk) => - !productPerks.some((productPerk) => productPerk.perkId === perk.id) - ); + const perksWithoutProductPerks = perks.filter( + (perk) => + !productPerks.some((productPerk) => productPerk.perkId === perk.id) + ); - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-
-
-

- {product.name} -

- {/* */} -
-
-
-
-
-

Perks

-

- Configure what perks this product unlocks. -

+ return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+
+
+

+ {product.name} +

+ {/* */} +
+
+
+
+
+

Perks

+

+ Configure what perks this product unlocks. +

-
- {productPerks.length === 0 && ( - - )} - {productPerks.length > 0 && ( - - - {productPerks.map((productPerk) => ( - - ))} - +
+ {productPerks.length === 0 && ( + + )} + {productPerks.length > 0 && ( + + + {productPerks.map((productPerk) => ( + + ))} + - - - - - )} -
-
- {environment !== EnvironmentEnum.Testing && ( -
-

- Payment Providers -

-

- Sets up a relationship between this voidhash product and payment - providers products. -

+ + + + + )} +
+
+ {environment !== EnvironmentEnum.Testing && ( +
+

+ Payment Providers +

+

+ Sets up a relationship between this voidhash product and payment + providers products. +

-
- {enabledPaymentProviderConfigurations.length === 0 && ( - - )} - {enabledPaymentProviderConfigurations.map( - (paymentProviderWithConfiguration) => ( - - - - - - {paymentProviderWithConfiguration.paymentProvider.getTitle()} - - - - - {/* Emtpy State */} - {providerProducts.filter( - (providerProduct) => - providerProduct.paymentProviderConfigurationId === - paymentProviderWithConfiguration.id - ).length === 0 && ( -
-
- You haven't added any{" "} - {paymentProviderWithConfiguration.paymentProvider.getTitle()}{" "} - product yet. -
-
- -
-
- )} +
+ {enabledPaymentProviderConfigurations.length === 0 && ( + + )} + {enabledPaymentProviderConfigurations.map( + (paymentProviderWithConfiguration) => ( + + + + + + {paymentProviderWithConfiguration.paymentProvider.getTitle()} + + + + + {/* Emtpy State */} + {providerProducts.filter( + (providerProduct) => + providerProduct.paymentProviderConfigurationId === + paymentProviderWithConfiguration.id + ).length === 0 && ( +
+
+ You haven't added any{' '} + {paymentProviderWithConfiguration.paymentProvider.getTitle()}{' '} + product yet. +
+
+ +
+
+ )} - {providerProducts - .filter( - (providerProduct) => - providerProduct.paymentProviderConfigurationId === - paymentProviderWithConfiguration.id - ) - .map((providerProduct) => ( - - ))} -
- {providerProducts.filter( - (providerProduct) => - providerProduct.paymentProviderConfigurationId === - paymentProviderWithConfiguration.id - ).length > 0 && ( - - - - )} -
- ) - )} -
-
- )} -
- - ); + {providerProducts + .filter( + (providerProduct) => + providerProduct.paymentProviderConfigurationId === + paymentProviderWithConfiguration.id + ) + .map((providerProduct) => ( + + ))} + + {providerProducts.filter( + (providerProduct) => + providerProduct.paymentProviderConfigurationId === + paymentProviderWithConfiguration.id + ).length > 0 && ( + + + + )} + + ) + )} +
+
+ )} +
+
+ ); } diff --git a/apps/web/features/products/product-detail-payment-providers-empty-state.tsx b/apps/web/features/products/product-detail-payment-providers-empty-state.tsx index bb0310cdb..b8259855a 100644 --- a/apps/web/features/products/product-detail-payment-providers-empty-state.tsx +++ b/apps/web/features/products/product-detail-payment-providers-empty-state.tsx @@ -1,32 +1,35 @@ import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Button, -} from "@voidhash/ui"; -import Link from "next/link"; + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@voidhash/ui'; +import Link from 'next/link'; export function ProductDetailPaymentProvidersEmptyState({ - projectSlug, - organizationSlug, -}: { projectSlug: string; organizationSlug: string }) { - return ( - - - No payment providers enabled - - Setup and enable at least one payment provider before proceeding. - - - - - - - - - ); + projectSlug, + organizationSlug +}: { + projectSlug: string; + organizationSlug: string; +}) { + return ( + + + No payment providers enabled + + Setup and enable at least one payment provider before proceeding. + + + + + + + + + ); } diff --git a/apps/web/features/products/product-detail-perks-empty-state.tsx b/apps/web/features/products/product-detail-perks-empty-state.tsx index 899c0aa7f..aa6a2dfe4 100644 --- a/apps/web/features/products/product-detail-perks-empty-state.tsx +++ b/apps/web/features/products/product-detail-perks-empty-state.tsx @@ -1,33 +1,33 @@ -"use client"; +'use client'; +import type { Perk } from '@voidhash/db'; import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "@voidhash/ui"; -import { ProductDetailAddPerkButton } from "./product-detail-add-perk-button"; -import type { Perk } from "@voidhash/db"; + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@voidhash/ui'; +import { ProductDetailAddPerkButton } from './product-detail-add-perk-button'; export function ProductDetailPerksEmptyState({ - productId, - perks, + productId, + perks }: { - productId: string; - perks: Perk[]; + productId: string; + perks: Perk[]; }) { - return ( - - - No perks configured - - Add perks that will be unlocked when this product is purchased. - - - - - - - ); + return ( + + + No perks configured + + Add perks that will be unlocked when this product is purchased. + + + + + + + ); } diff --git a/apps/web/features/products/product-detail-product-perk-record.tsx b/apps/web/features/products/product-detail-product-perk-record.tsx index fbcc00baf..c12b49586 100644 --- a/apps/web/features/products/product-detail-product-perk-record.tsx +++ b/apps/web/features/products/product-detail-product-perk-record.tsx @@ -1,98 +1,99 @@ -"use client"; +'use client'; +import type { Perk, ProductPerk } from '@voidhash/db'; import { - Badge, - DropdownMenu, - DropdownMenuTrigger, - Button, - DropdownMenuContent, - DropdownMenuItem, - useConfirmDialog, - cn, -} from "@voidhash/ui"; -import { EllipsisVerticalIcon } from "lucide-react"; -import { toast } from "sonner"; -import { useAction } from "next-safe-action/hooks"; -import { deleteProductPerkAction } from "@/lib/nextjs/server-actions"; -import { useRouter } from "next/navigation"; -import type { Perk, ProductPerk } from "@voidhash/db"; + Badge, + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + useConfirmDialog +} from '@voidhash/ui'; +import { EllipsisVerticalIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { toast } from 'sonner'; +import { deleteProductPerkAction } from '@/lib/nextjs/server-actions'; export function ProductDetailPerkRecord({ - productPerk, - perks, + productPerk, + perks }: { - productPerk: ProductPerk; - perks: Perk[]; + productPerk: ProductPerk; + perks: Perk[]; }) { - const router = useRouter(); - const perk = perks.find((p) => p.id === productPerk.perkId); + const router = useRouter(); + const perk = perks.find((p) => p.id === productPerk.perkId); - const { execute: deleteProductPerk, isPending } = useAction( - deleteProductPerkAction, - { - onSuccess: () => { - toast.success(`${perk?.name} perk was successfully deleted`); - router.refresh(); - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to delete ${perk?.name} perk. Please try again.` - ); - }, - } - ); + const { execute: deleteProductPerk, isPending } = useAction( + deleteProductPerkAction, + { + onSuccess: () => { + toast.success(`${perk?.name} perk was successfully deleted`); + router.refresh(); + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + `Failed to delete ${perk?.name} perk. Please try again.` + ); + } + } + ); - const { ConfirmationDialog, openDialog } = useConfirmDialog(); + const { ConfirmationDialog, openDialog } = useConfirmDialog(); - const handleDeleteProductPerk = async () => { - const res = await openDialog({ - title: "Delete product perk", - description: `Are you sure you want to remove this perk from this product? This may break access for customers who have already purchased this.`, - }); + const handleDeleteProductPerk = async () => { + const res = await openDialog({ + title: 'Delete product perk', + description: + 'Are you sure you want to remove this perk from this product? This may break access for customers who have already purchased this.' + }); - if (!res) { - return; - } + if (!res) { + return; + } - deleteProductPerk({ - productId: productPerk.productId, - perkId: productPerk.perkId, - }); - }; + deleteProductPerk({ + productId: productPerk.productId, + perkId: productPerk.perkId + }); + }; - if (!perk) { - return null; - } + if (!perk) { + return null; + } - return ( -
-
- - {perk.name} - -
-
- - - - - - - {isPending ? "Deleting..." : "Delete"} - - - -
- -
- ); + return ( +
+
+ + {perk.name} + +
+
+ + + + + + + {isPending ? 'Deleting...' : 'Delete'} + + + +
+ +
+ ); } diff --git a/apps/web/features/products/product-detail-provider-product-record.tsx b/apps/web/features/products/product-detail-provider-product-record.tsx index 49535f55e..a6b6898f4 100644 --- a/apps/web/features/products/product-detail-provider-product-record.tsx +++ b/apps/web/features/products/product-detail-provider-product-record.tsx @@ -1,190 +1,189 @@ -"use client"; +'use client'; +import type { PaymentProviderConfigurationProduct } from '@voidhash/db'; import { - Badge, - DropdownMenu, - DropdownMenuTrigger, - Button, - DropdownMenuContent, - DropdownMenuItem, - useConfirmDialog, - cn, -} from "@voidhash/ui"; -import { Clock4Icon, EllipsisVerticalIcon } from "lucide-react"; -import { format } from "date-fns"; - -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { toast } from "sonner"; -import { useAction } from "next-safe-action/hooks"; + Badge, + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + useConfirmDialog +} from '@voidhash/ui'; +import { format } from 'date-fns'; +import { Clock4Icon, EllipsisVerticalIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; import { - deletePaymentProviderProductAction, - setActivePaymentProviderProductAction, -} from "@/lib/nextjs/server-actions"; -import { useRouter } from "next/navigation"; -import { ProviderProductSheet } from "./provider-product-sheet"; -import { useState } from "react"; -import type { PaymentProviderConfigurationProduct } from "@voidhash/db"; + deletePaymentProviderProductAction, + setActivePaymentProviderProductAction +} from '@/lib/nextjs/server-actions'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; +import { ProviderProductSheet } from './provider-product-sheet'; export function ProductDetailProviderProductRecord({ - paymentProviderId, - paymentProviderConfigurationId, - providerProduct, + paymentProviderId, + paymentProviderConfigurationId, + providerProduct }: { - paymentProviderId: string; - paymentProviderConfigurationId: string; - providerProduct: PaymentProviderConfigurationProduct; + paymentProviderId: string; + paymentProviderConfigurationId: string; + providerProduct: PaymentProviderConfigurationProduct; }) { - const router = useRouter(); - const paymentProvider = paymentProviders.find( - (p) => p.getId() === paymentProviderId - ); + const router = useRouter(); + const paymentProvider = paymentProviders.find( + (p) => p.getId() === paymentProviderId + ); - const [openEditSheet, setOpenEditSheet] = useState(false); + const [openEditSheet, setOpenEditSheet] = useState(false); - const { execute: deleteProviderProduct, isPending } = useAction( - deletePaymentProviderProductAction, - { - onSuccess: () => { - toast.success( - `${paymentProvider?.getTitle()} product was successfully deleted` - ); - router.refresh(); - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to delete ${paymentProvider?.getTitle()} product. Please try again.` - ); - }, - } - ); + const { execute: deleteProviderProduct, isPending } = useAction( + deletePaymentProviderProductAction, + { + onSuccess: () => { + toast.success( + `${paymentProvider?.getTitle()} product was successfully deleted` + ); + router.refresh(); + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + `Failed to delete ${paymentProvider?.getTitle()} product. Please try again.` + ); + } + } + ); - const { - execute: setActiveProviderProduct, - isPending: isSettingActiveProviderProduct, - } = useAction(setActivePaymentProviderProductAction, { - onSuccess: () => { - toast.success( - `${paymentProvider?.getTitle()} product was successfully activated` - ); - router.refresh(); - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to activate ${paymentProvider?.getTitle()} product. Please try again.` - ); - }, - }); + const { + execute: setActiveProviderProduct, + isPending: isSettingActiveProviderProduct + } = useAction(setActivePaymentProviderProductAction, { + onSuccess: () => { + toast.success( + `${paymentProvider?.getTitle()} product was successfully activated` + ); + router.refresh(); + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + `Failed to activate ${paymentProvider?.getTitle()} product. Please try again.` + ); + } + }); - const handleSetActiveProviderProduct = async () => { - setActiveProviderProduct({ - productId: providerProduct.productId, - paymentProviderConfigurationId: paymentProviderConfigurationId, - providerProductKey: providerProduct.providerProductKey, - }); - }; + const handleSetActiveProviderProduct = () => { + setActiveProviderProduct({ + productId: providerProduct.productId, + paymentProviderConfigurationId, + providerProductKey: providerProduct.providerProductKey + }); + }; - const { ConfirmationDialog, openDialog } = useConfirmDialog(); + const { ConfirmationDialog, openDialog } = useConfirmDialog(); - const handleDeleteProviderProduct = async () => { - if (!paymentProvider) { - return; - } + const handleDeleteProviderProduct = async () => { + if (!paymentProvider) { + return; + } - const res = await openDialog({ - title: "Delete product", - description: `Are you sure you want to delete this ${paymentProvider.getTitle()} product? This may break access for customers who have already purchased this.`, - }); + const res = await openDialog({ + title: 'Delete product', + description: `Are you sure you want to delete this ${paymentProvider.getTitle()} product? This may break access for customers who have already purchased this.` + }); - if (!res) { - return; - } + if (!res) { + return; + } - deleteProviderProduct({ - productId: providerProduct.productId, - paymentProviderConfigurationId: paymentProviderConfigurationId, - providerProductKey: providerProduct.providerProductKey, - }); - }; + deleteProviderProduct({ + productId: providerProduct.productId, + paymentProviderConfigurationId, + providerProductKey: providerProduct.providerProductKey + }); + }; - if (!paymentProvider) { - return null; - } + if (!paymentProvider) { + return null; + } - return ( -
-
- {paymentProvider.getProductKeyProperties().map((key) => ( - - {providerProduct.configuration?.[key]} - - ))} -
-
-
- {providerProduct.isActive && ( -
- Active -
- )} -
- - - {format(providerProduct.createdAt ?? new Date(), "MMM d, yyyy")} - -
-
-
- - - - - - setOpenEditSheet(true)}> - Edit - - - {isSettingActiveProviderProduct ? "Activating..." : "Activate"} - - - {isPending ? "Deleting..." : "Delete"} - - - -
- - setOpenEditSheet(false)} - paymentProviderConfigurationId={paymentProviderConfigurationId} - paymentProviderConfigurationProductId={providerProduct.id} - providerId={paymentProviderId} - productId={providerProduct.productId} - mode={"edit"} - configuration={providerProduct.configuration} - /> -
- ); + return ( +
+
+ {paymentProvider.getProductKeyProperties().map((key) => ( + + {providerProduct.configuration?.[key]} + + ))} +
+
+
+ {providerProduct.isActive && ( +
+ Active +
+ )} +
+ + + {format(providerProduct.createdAt ?? new Date(), 'MMM d, yyyy')} + +
+
+
+ + + + + + setOpenEditSheet(true)}> + Edit + + + {isSettingActiveProviderProduct ? 'Activating...' : 'Activate'} + + + {isPending ? 'Deleting...' : 'Delete'} + + + +
+ + setOpenEditSheet(false)} + open={openEditSheet} + paymentProviderConfigurationId={paymentProviderConfigurationId} + paymentProviderConfigurationProductId={providerProduct.id} + productId={providerProduct.productId} + providerId={paymentProviderId} + /> +
+ ); } diff --git a/apps/web/features/products/product-record-configuration-state-indicator.tsx b/apps/web/features/products/product-record-configuration-state-indicator.tsx index b727a3eae..fbbe3b730 100644 --- a/apps/web/features/products/product-record-configuration-state-indicator.tsx +++ b/apps/web/features/products/product-record-configuration-state-indicator.tsx @@ -1,77 +1,80 @@ -import { Badge } from "@voidhash/ui"; -import { PaymentProviderLogo } from "../projects/settings/payment-providers/payment-provider-logo"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { Effect } from "effect"; -import { ProductService } from "@/lib/services/product.service"; -import { PaymentProviderService } from "@/lib/services/payment-provider.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Badge } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { PaymentProviderService } from '@/lib/services/payment-provider.service'; +import { ProductService } from '@/lib/services/product.service'; +import { PaymentProviderLogo } from '../projects/settings/payment-providers/payment-provider-logo'; export async function ProductRecordConfigurationStateIndicator({ - productId, - projectId, + productId, + projectId }: { - productId: string; - projectId: string; + productId: string; + projectId: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const productService = yield* ProductService; - const paymentProviderService = yield* PaymentProviderService; + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const productService = yield* ProductService; + const paymentProviderService = yield* PaymentProviderService; - const [providerProducts, paymentProviderConfigurations] = - yield* Effect.all([ - productService.getProviderProductsByProductId(productId), - paymentProviderService.getPaymentProviderConfigurations( - projectId - ), - ], { - concurrency: "unbounded" - }); - return { providerProducts, paymentProviderConfigurations }; - }) - ); - }) - ); + const [providerProducts, paymentProviderConfigurations] = + yield* Effect.all( + [ + productService.getProviderProductsByProductId(productId), + paymentProviderService.getPaymentProviderConfigurations( + projectId + ) + ], + { + concurrency: 'unbounded' + } + ); + return { providerProducts, paymentProviderConfigurations }; + }) + ); + }) + ); - if (data.isErr()) { - return Loading error; - } + if (data.isErr()) { + return Loading error; + } - const { providerProducts, paymentProviderConfigurations } = data.value; + const { providerProducts, paymentProviderConfigurations } = data.value; - if (providerProducts.length === 0) { - return Configuration required; - } + if (providerProducts.length === 0) { + return Configuration required; + } - if (paymentProviderConfigurations.length === 0) { - return Configuration required; - } + if (paymentProviderConfigurations.length === 0) { + return Configuration required; + } - return ( -
- {paymentProviderConfigurations - .filter((f) => !!f.enabled) - .map((paymentProviderConfiguration) => { - return providerProducts.some( - (providerProduct) => - providerProduct.paymentProviderConfigurationId === - paymentProviderConfiguration.id - ) ? ( - - ) : null; - })} -
- ); + return ( +
+ {paymentProviderConfigurations + .filter((f) => !!f.enabled) + .map((paymentProviderConfiguration) => { + return providerProducts.some( + (providerProduct) => + providerProduct.paymentProviderConfigurationId === + paymentProviderConfiguration.id + ) ? ( + + ) : null; + })} +
+ ); } diff --git a/apps/web/features/products/product-record-skeleton.tsx b/apps/web/features/products/product-record-skeleton.tsx index 85c66ac15..94da94551 100644 --- a/apps/web/features/products/product-record-skeleton.tsx +++ b/apps/web/features/products/product-record-skeleton.tsx @@ -1,20 +1,20 @@ -"use client"; +'use client'; -import { Skeleton } from "@voidhash/ui"; +import { Skeleton } from '@voidhash/ui'; export function ProductRecordSkeleton() { - return ( -
-
-
-
- -
-
-
- -
-
-
- ); + return ( +
+
+
+
+ +
+
+
+ +
+
+
+ ); } diff --git a/apps/web/features/products/product-record.tsx b/apps/web/features/products/product-record.tsx index 04c1137a1..e24dc15fe 100644 --- a/apps/web/features/products/product-record.tsx +++ b/apps/web/features/products/product-record.tsx @@ -1,111 +1,112 @@ -"use client"; -import { deleteProductAction } from "@/lib/nextjs/server-actions"; +'use client'; +import type { Product } from '@voidhash/db'; import { - DropdownMenu, - DropdownMenuTrigger, - Button, - DropdownMenuContent, - DropdownMenuItem, - useConfirmDialog, -} from "@voidhash/ui"; -import { useAction } from "next-safe-action/hooks"; -import { EllipsisVerticalIcon } from "lucide-react"; -import Link from "next/link"; -import { useState } from "react"; -import { toast } from "sonner"; -import { useRouter } from "next/navigation"; -import { EditProductModal } from "./edit-product-modal"; -import { Product } from "@voidhash/db"; + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + useConfirmDialog +} from '@voidhash/ui'; +import { EllipsisVerticalIcon } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { deleteProductAction } from '@/lib/nextjs/server-actions'; +import { EditProductModal } from './edit-product-modal'; export function ProductRecord({ - product, - configurationStateIndicator, - organizationSlug, - projectSlug, + product, + configurationStateIndicator, + organizationSlug, + projectSlug }: { - product: Product; - configurationStateIndicator: React.ReactNode; - organizationSlug: string; - projectSlug: string; + product: Product; + configurationStateIndicator: React.ReactNode; + organizationSlug: string; + projectSlug: string; }) { - const router = useRouter(); - const [openEditModal, setOpenEditModal] = useState(false); + const router = useRouter(); + const [openEditModal, setOpenEditModal] = useState(false); - const { execute: deleteProduct, isPending } = useAction(deleteProductAction, { - onSuccess: () => { - toast.success(`Product was successfully deleted`); - router.refresh(); - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to delete the product. Please try again.` - ); - }, - }); + const { execute: deleteProduct, isPending } = useAction(deleteProductAction, { + onSuccess: () => { + toast.success('Product was successfully deleted'); + router.refresh(); + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + 'Failed to delete the product. Please try again.' + ); + } + }); - const { ConfirmationDialog, openDialog } = useConfirmDialog(); + const { ConfirmationDialog, openDialog } = useConfirmDialog(); - const handleDeleteProduct = async () => { - const res = await openDialog({ - title: "Delete product", - description: `Are you sure you want to delete this product? This may break access for customers who have already purchased this.`, - }); + const handleDeleteProduct = async () => { + const res = await openDialog({ + title: 'Delete product', + description: + 'Are you sure you want to delete this product? This may break access for customers who have already purchased this.' + }); - if (!res) { - return; - } + if (!res) { + return; + } - deleteProduct({ - productId: product.id, - }); - }; + deleteProduct({ + productId: product.id + }); + }; - return ( -
- -
-
-
{product.name}
-
{configurationStateIndicator}
-
-
- - - - - - { - e.preventDefault(); - setOpenEditModal(true); - }} - > - Edit product - - - {isPending ? "Deleting..." : "Delete product"} - - - -
-
- - setOpenEditModal(false)} - product={product} - /> -
- ); + return ( +
+ +
+
+
{product.name}
+
{configurationStateIndicator}
+
+
+ + + + + + { + e.preventDefault(); + setOpenEditModal(true); + }} + > + Edit product + + + {isPending ? 'Deleting...' : 'Delete product'} + + + +
+
+ + setOpenEditModal(false)} + open={openEditModal} + product={product} + /> +
+ ); } diff --git a/apps/web/features/products/products-page-empty-state.tsx b/apps/web/features/products/products-page-empty-state.tsx index b210d9f2f..a140dfd69 100644 --- a/apps/web/features/products/products-page-empty-state.tsx +++ b/apps/web/features/products/products-page-empty-state.tsx @@ -1,39 +1,39 @@ -"use client"; +'use client'; import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Button, -} from "@voidhash/ui"; -import { useState } from "react"; -import { CreateProductModal } from "./create-product-modal"; + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@voidhash/ui'; +import { useState } from 'react'; +import { CreateProductModal } from './create-product-modal'; export function ProductsPageEmptyState({ projectId }: { projectId: string }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); - return ( - - - No products yet - - Products are items customers can purchase (e.g. Gold Monthly, Gold - Yearly, All-Access Pass etc.). Get started by creating a product. - - - - setOpen(false)} - trigger={ - - } - projectId={projectId} - onSuccess={() => setOpen(false)} - /> - - - ); + return ( + + + No products yet + + Products are items customers can purchase (e.g. Gold Monthly, Gold + Yearly, All-Access Pass etc.). Get started by creating a product. + + + + setOpen(false)} + onSuccess={() => setOpen(false)} + open={open} + projectId={projectId} + trigger={ + + } + /> + + + ); } diff --git a/apps/web/features/products/products-page-skeleton.tsx b/apps/web/features/products/products-page-skeleton.tsx index bfa8e0028..ac110c3b6 100644 --- a/apps/web/features/products/products-page-skeleton.tsx +++ b/apps/web/features/products/products-page-skeleton.tsx @@ -1,26 +1,27 @@ -import { Page } from "@/features/shell"; -import { Card } from "@voidhash/ui"; -import { ProductRecordSkeleton } from "./product-record-skeleton"; +import { Card } from '@voidhash/ui'; +import { Page } from '@/features/shell'; +import { ProductRecordSkeleton } from './product-record-skeleton'; export function ProductsPageSkeleton() { - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-
-

Products

-
-

- List of products available to purchase. -

-
- - {Array.from({ length: 3 }).map((_, index) => ( - - ))} - -
-
-
- ); + return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+
+

Products

+
+

+ List of products available to purchase. +

+
+ + {Array.from({ length: 3 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton + + ))} + +
+
+
+ ); } diff --git a/apps/web/features/products/products-page.tsx b/apps/web/features/products/products-page.tsx index e91999e86..97eb82b72 100644 --- a/apps/web/features/products/products-page.tsx +++ b/apps/web/features/products/products-page.tsx @@ -1,109 +1,109 @@ -import { Page } from "@/features/shell"; -import { CreateProductModalButton } from "./create-product-modal-button"; -import { Card } from "@voidhash/ui"; -import { ProductRecord } from "./product-record"; -import { ProductsPageEmptyState } from "./products-page-empty-state"; -import { ProductRecordConfigurationStateIndicator } from "./product-record-configuration-state-indicator"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { Effect } from "effect"; -import { ProjectService } from "@/lib/services/project.service"; +import { Card } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { Page } from '@/features/shell'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; -import { NotFoundError } from "@/lib/effect/errors"; -import { ProductService } from "@/lib/services/product.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { ProductService } from '@/lib/services/product.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { CreateProductModalButton } from './create-product-modal-button'; +import { ProductRecord } from './product-record'; +import { ProductRecordConfigurationStateIndicator } from './product-record-configuration-state-indicator'; +import { ProductsPageEmptyState } from './products-page-empty-state'; export async function ProductsPage({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string; - projectSlug; + organizationSlug: string; + projectSlug; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - organizationSlug, - projectSlug, - }); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const productService = yield* ProductService; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - const products = yield* productService.getProducts(project.id); - return { project, products }; - }) - ); - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + organizationSlug, + projectSlug + }); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const productService = yield* ProductService; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + const products = yield* productService.getProducts(project.id); + return { project, products }; + }) + ); + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { project, products } = data.value; + const { project, products } = data.value; - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-
-

Products

- {products.length > 0 && ( - - )} -
-

- List of products available to purchase. -

+ return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+
+

Products

+ {products.length > 0 && ( + + )} +
+

+ List of products available to purchase. +

-
- {products.length === 0 ? ( - - ) : ( - - {products.map((product) => ( - - } - /> - ))} - - )} -
-
-
- ); +
+ {products.length === 0 ? ( + + ) : ( + + {products.map((product) => ( + + } + key={product.id} + organizationSlug={organizationSlug} + product={product} + projectSlug={projectSlug} + /> + ))} + + )} +
+
+
+ ); } diff --git a/apps/web/features/products/provider-product-sheet.tsx b/apps/web/features/products/provider-product-sheet.tsx index 5ba19c542..6881b43cc 100644 --- a/apps/web/features/products/provider-product-sheet.tsx +++ b/apps/web/features/products/provider-product-sheet.tsx @@ -1,225 +1,225 @@ -"use client"; +/** biome-ignore-all lint/suspicious/noExplicitAny: any */ +'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; import { - createPaymentProviderProductAction, - updatePaymentProviderProductAction, -} from "@/lib/nextjs/server-actions"; -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { zodResolver } from "@hookform/resolvers/zod"; + Button, + CopyText, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Label, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle +} from '@voidhash/ui'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { Fragment, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; import { - Form, - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - Label, - FormField, - FormItem, - FormLabel, - FormControl, - Input, - FormMessage, - CopyText, - SheetFooter, - Button, -} from "@voidhash/ui"; -import { useAction } from "next-safe-action/hooks"; -import { useRouter } from "next/navigation"; -import { Fragment, useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; + createPaymentProviderProductAction, + updatePaymentProviderProductAction +} from '@/lib/nextjs/server-actions'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; export function ProviderProductSheet({ - open, - onClose, - paymentProviderConfigurationProductId, - productId, - paymentProviderConfigurationId, - providerId, - configuration, - mode, + open, + onClose, + paymentProviderConfigurationProductId, + productId, + paymentProviderConfigurationId, + providerId, + configuration, + mode }: { - open: boolean; - onClose: () => void; - productId: string; - paymentProviderConfigurationId: string; - paymentProviderConfigurationProductId?: string; - providerId: string; - mode: "add" | "edit"; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - configuration?: any; + open: boolean; + onClose: () => void; + productId: string; + paymentProviderConfigurationId: string; + paymentProviderConfigurationProductId?: string; + providerId: string; + mode: 'add' | 'edit'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + configuration?: any; }) { - const router = useRouter(); - const paymentProvider = paymentProviders.find( - (pp) => pp.getId() === providerId - ); + const router = useRouter(); + const paymentProvider = paymentProviders.find( + (pp) => pp.getId() === providerId + ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const form = useForm({ - resolver: zodResolver( - paymentProvider?.getProductConfigurationSchema() ?? z.object({}) - ), - defaultValues: paymentProvider?.getDefaultProductConfiguration(), - }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const form = useForm({ + resolver: zodResolver( + paymentProvider?.getProductConfigurationSchema() ?? z.object({}) + ), + defaultValues: paymentProvider?.getDefaultProductConfiguration() + }); - const { execute: create, isPending: createPending } = useAction( - createPaymentProviderProductAction, - { - onSuccess: () => { - toast.success( - `${paymentProvider?.getTitle()} configuration saved successfully` - ); - onClose(); - router.refresh(); - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to save ${paymentProvider?.getTitle()} configuration. Please try again.` - ); - }, - } - ); + const { execute: create, isPending: createPending } = useAction( + createPaymentProviderProductAction, + { + onSuccess: () => { + toast.success( + `${paymentProvider?.getTitle()} configuration saved successfully` + ); + onClose(); + router.refresh(); + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + `Failed to save ${paymentProvider?.getTitle()} configuration. Please try again.` + ); + } + } + ); - const { execute: update, isPending: updatePending } = useAction( - updatePaymentProviderProductAction, - { - onSuccess: () => { - toast.success( - `${paymentProvider?.getTitle()} configuration saved successfully` - ); - onClose(); - router.refresh(); - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to save ${paymentProvider?.getTitle()} configuration. Please try again.` - ); - }, - } - ); + const { execute: update, isPending: updatePending } = useAction( + updatePaymentProviderProductAction, + { + onSuccess: () => { + toast.success( + `${paymentProvider?.getTitle()} configuration saved successfully` + ); + onClose(); + router.refresh(); + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + `Failed to save ${paymentProvider?.getTitle()} configuration. Please try again.` + ); + } + } + ); - const isPending = createPending || updatePending; + const isPending = createPending || updatePending; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const onSubmit = async (data: any) => { - if (mode === "add") { - create({ - productId, - paymentProviderConfigurationId, - configuration: data, - }); - } else { - if (!paymentProviderConfigurationProductId) { - toast.error("An error occurred while saving the configuration"); - return; - } - update({ - paymentProviderConfigurationProductId: paymentProviderConfigurationProductId, - configuration: data, - }); - } - }; + const onSubmit = (data: any) => { + if (mode === 'add') { + create({ + productId, + paymentProviderConfigurationId, + configuration: data + }); + } else { + if (!paymentProviderConfigurationProductId) { + toast.error('An error occurred while saving the configuration'); + return; + } + update({ + paymentProviderConfigurationProductId, + configuration: data + }); + } + }; - useEffect(() => { - if (open) { - form.reset( - configuration ?? paymentProvider?.getDefaultProductConfiguration() ?? {} - ); - } - }, [open]); + useEffect(() => { + if (!open) { + form.reset( + configuration ?? paymentProvider?.getDefaultProductConfiguration() ?? {} + ); + } + }, [open, configuration, form, paymentProvider]); - if (!paymentProvider) { - return null; - } + if (!paymentProvider) { + return null; + } - const configurationSheet = paymentProvider.getProductConfigurationSheet(); + const configurationSheet = paymentProvider.getProductConfigurationSheet(); - return ( - { - if (!open) { - onClose(); - } - }} - > - - - - {mode === "add" - ? `Add ${paymentProvider.getTitle()} Product` - : `Edit ${paymentProvider.getTitle()} Product`} - - + return ( + { + if (!open) { + onClose(); + } + }} + open={open} + > + + + + {mode === 'add' + ? `Add ${paymentProvider.getTitle()} Product` + : `Edit ${paymentProvider.getTitle()} Product`} + + -
- -
- {form.formState.errors.root && ( -
- {form.formState.errors.root.message} -
- )} -
+ + +
+ {form.formState.errors.root && ( +
+ {form.formState.errors.root.message} +
+ )} +
-
- {configurationSheet.sections.map((section) => ( - - {section.type === "text-input" && ( - ( - - {section.label} - - - - - - )} - /> - )} - {section.type === "copy-text" && ( -
- -
- -
-
- )} -
- ))} -
- -
- - -
-
-
- -
-
- ); +
+ {configurationSheet.sections.map((section) => ( + + {section.type === 'text-input' && ( + ( + + {section.label} + + + + + + )} + /> + )} + {section.type === 'copy-text' && ( +
+ +
+ +
+
+ )} +
+ ))} +
+ +
+ + +
+
+ + +
+
+ ); } diff --git a/apps/web/features/projects/create-project-modal.tsx b/apps/web/features/projects/create-project-modal.tsx index 96c45bf8f..b1a1b6a81 100644 --- a/apps/web/features/projects/create-project-modal.tsx +++ b/apps/web/features/projects/create-project-modal.tsx @@ -1,131 +1,132 @@ -"use client"; -import { Button } from "@voidhash/ui/button"; +'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQueryClient } from '@tanstack/react-query'; +import { Button } from '@voidhash/ui/button'; import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@voidhash/ui/dialog'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { useRouter } from "next/navigation"; -import { useAction } from "next-safe-action/hooks"; -import { createProjectAction } from "@/lib/nextjs/server-actions"; -import { useTRPC } from "../trpc/react"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { createProjectAction } from '@/lib/nextjs/server-actions'; +import { useTRPC } from '../trpc/react'; + const createProjectSchema = z.object({ - name: z - .string() - .min(1, "Project name is required") - .max(32, "Project name must be less than 32 characters"), + name: z + .string() + .min(1, 'Project name is required') + .max(32, 'Project name must be less than 32 characters') }); type CreateProjectForm = z.infer; interface CreateProjectModalProps { - open: boolean; - onClose: () => void; - trigger: React.ReactNode; - organizationId: string; - organizationSlug: string; + open: boolean; + onClose: () => void; + trigger: React.ReactNode; + organizationId: string; + organizationSlug: string; } export function CreateProjectModal({ - open, - onClose, - trigger, - organizationId, - organizationSlug, + open, + onClose, + trigger, + organizationId, + organizationSlug }: CreateProjectModalProps) { - const router = useRouter(); + const router = useRouter(); - const form = useForm({ - resolver: zodResolver(createProjectSchema), - defaultValues: { - name: "", - }, - }); + const form = useForm({ + resolver: zodResolver(createProjectSchema), + defaultValues: { + name: '' + } + }); - const queryClient = useQueryClient(); - const trpc = useTRPC(); + const queryClient = useQueryClient(); + const trpc = useTRPC(); - const { execute, isPending } = useAction(createProjectAction, { - onSuccess: async (res) => { - queryClient.invalidateQueries(); - router.push(`/${organizationSlug}/${res.data?.slug}`); - queryClient.invalidateQueries({ - queryKey: trpc.projects.pathKey(), - }); - onClose?.(); - }, - onError: (error) => { - toast.error(error.error.serverError); - }, - }); + const { execute, isPending } = useAction(createProjectAction, { + onSuccess: (res) => { + queryClient.invalidateQueries(); + router.push(`/${organizationSlug}/${res.data?.slug}`); + queryClient.invalidateQueries({ + queryKey: trpc.projects.pathKey() + }); + onClose?.(); + }, + onError: (error) => { + toast.error(error.error.serverError); + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + } + }; - const onSubmit = (data: CreateProjectForm) => { - execute({ - ...data, - organizationId, - }); - }; + const onSubmit = (data: CreateProjectForm) => { + execute({ + ...data, + organizationId + }); + }; - return ( - - {trigger} - - - Create New Project - -
- - ( - - Name - - - - - - )} - /> - - - - - -
-
- ); + return ( + + {trigger} + + + Create New Project + +
+ + ( + + Name + + + + + + )} + /> + + + + + +
+
+ ); } diff --git a/apps/web/features/projects/delete-project-modal.tsx b/apps/web/features/projects/delete-project-modal.tsx index 6157f1be7..7d0ea7efc 100644 --- a/apps/web/features/projects/delete-project-modal.tsx +++ b/apps/web/features/projects/delete-project-modal.tsx @@ -1,124 +1,124 @@ -import { Button } from "@voidhash/ui/button"; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@voidhash/ui/button'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@voidhash/ui/dialog"; -import { Input } from "@voidhash/ui/input"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@voidhash/ui/dialog'; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@voidhash/ui/form"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@voidhash/ui/form'; +import { Input } from '@voidhash/ui/input'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; interface DeleteProjectModalProps { - open: boolean; - onClose: () => void; - onDelete: () => void; - trigger: React.ReactNode; - organizationSlug: string; - projectSlug: string; + open: boolean; + onClose: () => void; + onDelete: () => void; + trigger: React.ReactNode; + organizationSlug: string; + projectSlug: string; } type DeleteProjectForm = { - confirmation: string; + confirmation: string; }; export function DeleteProjectModal({ - open, - onClose, - onDelete, - trigger, - organizationSlug, - projectSlug, + open, + onClose, + onDelete, + trigger, + organizationSlug, + projectSlug }: DeleteProjectModalProps) { - const deleteProjectSchema = z.object({ - confirmation: z - .string() - .refine((value) => value === `${organizationSlug}/${projectSlug}`, { - message: - "Please enter the text exactly as it is shown to confirm deletion", - }), - }); + const deleteProjectSchema = z.object({ + confirmation: z + .string() + .refine((value) => value === `${organizationSlug}/${projectSlug}`, { + message: + 'Please enter the text exactly as it is shown to confirm deletion' + }) + }); - const form = useForm({ - resolver: zodResolver(deleteProjectSchema), - defaultValues: { - confirmation: "", - }, - }); + const form = useForm({ + resolver: zodResolver(deleteProjectSchema), + defaultValues: { + confirmation: '' + } + }); - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose?.(); - } - }; + const handleOpenChange = (open: boolean) => { + if (!open) { + onClose?.(); + } + }; - const onSubmit = () => { - onClose(); - onDelete(); - }; + const onSubmit = () => { + onClose(); + onDelete(); + }; - return ( - - {trigger} - - - Delete Project - - This action cannot be undone. This will permanently delete the - project and all associated data. - - -
- - ( - - - Please type{" "} - - {organizationSlug}/{projectSlug} - {" "} - to confirm. - - - - - - - )} - /> + return ( + + {trigger} + + + Delete Project + + This action cannot be undone. This will permanently delete the + project and all associated data. + + + + + ( + + + Please type{' '} + + {organizationSlug}/{projectSlug} + {' '} + to confirm. + + + + + + + )} + /> - - - - - - - - - ); + + + + + + +
+
+ ); } diff --git a/apps/web/features/projects/settings/general/project-delete.tsx b/apps/web/features/projects/settings/general/project-delete.tsx index ba4d54690..7c4357a70 100644 --- a/apps/web/features/projects/settings/general/project-delete.tsx +++ b/apps/web/features/projects/settings/general/project-delete.tsx @@ -1,85 +1,85 @@ -"use client"; +'use client'; -import { DeleteProjectModal } from "@/features/projects/delete-project-modal"; +import { useQueryClient } from '@tanstack/react-query'; import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardFooter, - Button, -} from "@voidhash/ui"; -import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; -import { useAction } from "next-safe-action/hooks"; -import { deleteProjectAction } from "@/lib/nextjs/server-actions"; -import { toast } from "sonner"; -import { useQueryClient } from "@tanstack/react-query"; -import { useTRPC } from "@/features/trpc/react"; + Button, + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@voidhash/ui'; +import { useParams, useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { DeleteProjectModal } from '@/features/projects/delete-project-modal'; +import { useTRPC } from '@/features/trpc/react'; +import { deleteProjectAction } from '@/lib/nextjs/server-actions'; export function ProjectDelete({ projectId }: { projectId: string }) { - const { organizationSlug, projectSlug } = useParams(); - const router = useRouter(); - const queryClient = useQueryClient(); - const trpc = useTRPC(); + const { organizationSlug, projectSlug } = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const trpc = useTRPC(); - const { execute, isPending } = useAction(deleteProjectAction, { - onSuccess: () => { - toast.success("Project deleted successfully"); - queryClient.invalidateQueries({ - queryKey: trpc.pathKey(), - }); - router.push("/"); - }, - onError: (error) => { - toast.error(error.error.serverError); - }, - }); + const { execute, isPending } = useAction(deleteProjectAction, { + onSuccess: () => { + toast.success('Project deleted successfully'); + queryClient.invalidateQueries({ + queryKey: trpc.pathKey() + }); + router.push('/'); + }, + onError: (error) => { + toast.error(error.error.serverError); + } + }); - const handleDelete = () => { - execute({ - id: projectId, - }); - }; + const handleDelete = () => { + execute({ + id: projectId + }); + }; - // Delete modal - const [deleteModalOpen, setDeleteModalOpen] = useState(false); + // Delete modal + const [deleteModalOpen, setDeleteModalOpen] = useState(false); - if (typeof organizationSlug !== "string" || typeof projectSlug !== "string") { - return null; - } + if (typeof organizationSlug !== 'string' || typeof projectSlug !== 'string') { + return null; + } - return ( - - - Delete Project - - Permanently delete your project and all associated data. This action - is irreversible. - - - -
-
- setDeleteModalOpen(false)} - onDelete={handleDelete} - key={deleteModalOpen ? "open" : "closed"} - trigger={ - - } - organizationSlug={organizationSlug} - projectSlug={projectSlug} - /> -
-
-
- ); + return ( + + + Delete Project + + Permanently delete your project and all associated data. This action + is irreversible. + + + +
+
+ setDeleteModalOpen(false)} + onDelete={handleDelete} + open={deleteModalOpen} + organizationSlug={organizationSlug} + projectSlug={projectSlug} + trigger={ + + } + /> +
+ + + ); } diff --git a/apps/web/features/projects/settings/general/project-name.tsx b/apps/web/features/projects/settings/general/project-name.tsx index 3f7b43c27..532d91b61 100644 --- a/apps/web/features/projects/settings/general/project-name.tsx +++ b/apps/web/features/projects/settings/general/project-name.tsx @@ -1,117 +1,119 @@ -"use client"; +'use client'; -import { updateProjectAction } from "@/lib/nextjs/server-actions"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQueryClient } from '@tanstack/react-query'; +import type { Project } from '@voidhash/db'; import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - FormField, - FormItem, - FormControl, - Input, - FormMessage, - CardFooter, - Button, - Form, -} from "@voidhash/ui"; -import { useAction } from "next-safe-action/hooks"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { toast } from "sonner"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { useTRPC } from "@/features/trpc/react"; -import type { Project } from "@voidhash/db"; + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Form, + FormControl, + FormField, + FormItem, + FormMessage, + Input +} from '@voidhash/ui'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { useTRPC } from '@/features/trpc/react'; +import { updateProjectAction } from '@/lib/nextjs/server-actions'; const updateProjectNameSchema = z.object({ - name: z - .string() - .min(1, "Project name is required") - .max(32, "Project name must be less than 32 characters"), + name: z + .string() + .min(1, 'Project name is required') + .max(32, 'Project name must be less than 32 characters') }); type UpdateProjectNameForm = z.infer; export function ProjectNameForm({ project }: { project: Project }) { - const form = useForm({ - resolver: zodResolver(updateProjectNameSchema), - defaultValues: { - name: project?.name, - }, - }); + const form = useForm({ + resolver: zodResolver(updateProjectNameSchema), + defaultValues: { + name: project?.name + } + }); - const queryClient = useQueryClient(); - const trpc = useTRPC(); + const queryClient = useQueryClient(); + const trpc = useTRPC(); - const router = useRouter(); + const router = useRouter(); - const { execute: updateProjectName, isPending } = useAction( - updateProjectAction, - { - onSuccess: () => { - toast.success("Project name updated successfully"); - queryClient.invalidateQueries({ - queryKey: trpc.pathKey(), - }); - router.refresh(); - }, - onError: (error) => { - toast.error(error.error.serverError); - }, - } - ); + const { execute: updateProjectName, isPending } = useAction( + updateProjectAction, + { + onSuccess: () => { + toast.success('Project name updated successfully'); + queryClient.invalidateQueries({ + queryKey: trpc.pathKey() + }); + router.refresh(); + }, + onError: (error) => { + toast.error(error.error.serverError); + } + } + ); - const onSubmit = (data: UpdateProjectNameForm) => { - if (!project) return; - updateProjectName({ - id: project.id, - name: data.name, - }); - }; + const onSubmit = (data: UpdateProjectNameForm) => { + if (!project) { + return; + } + updateProjectName({ + id: project.id, + name: data.name + }); + }; - return ( -
- - - - Project Name - - This is your project's visible name within Voidhash. - - - - ( - - - - - - - )} - /> - - -
- Please use 32 characters at maximum. -
-
- -
-
-
-
- - ); + return ( +
+ + + + Project Name + + This is your project's visible name within Voidhash. + + + + ( + + + + + + + )} + /> + + +
+ Please use 32 characters at maximum. +
+
+ +
+
+
+
+ + ); } diff --git a/apps/web/features/projects/settings/general/project-settings-general-layout.tsx b/apps/web/features/projects/settings/general/project-settings-general-layout.tsx index 43e4bcd5a..d8122e94b 100644 --- a/apps/web/features/projects/settings/general/project-settings-general-layout.tsx +++ b/apps/web/features/projects/settings/general/project-settings-general-layout.tsx @@ -1,21 +1,21 @@ -import { Page } from "@/features/shell"; +import { Page } from '@/features/shell'; export function ProjectSettingsGeneralLayout({ - children, + children }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-

- Project Settings -

-

All settings for project

+ return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+

+ Project Settings +

+

All settings for project

- {children} -
-
- ); + {children} +
+
+ ); } diff --git a/apps/web/features/projects/settings/general/project-settings-general-page-skeleton.tsx b/apps/web/features/projects/settings/general/project-settings-general-page-skeleton.tsx index 19fed582a..54afd83cc 100644 --- a/apps/web/features/projects/settings/general/project-settings-general-page-skeleton.tsx +++ b/apps/web/features/projects/settings/general/project-settings-general-page-skeleton.tsx @@ -1,16 +1,16 @@ -import { SettingsCardSkeleton } from "@voidhash/ui"; -import { ProjectSettingsGeneralLayout } from "./project-settings-general-layout"; +import { SettingsCardSkeleton } from '@voidhash/ui'; +import { ProjectSettingsGeneralLayout } from './project-settings-general-layout'; export function ProjectSettingsGeneralPageSkeleton() { - return ( - - - - - ); + return ( + + + + + ); } diff --git a/apps/web/features/projects/settings/general/project-settings-general-page.tsx b/apps/web/features/projects/settings/general/project-settings-general-page.tsx index f9ee1d3d5..dbe3e394f 100644 --- a/apps/web/features/projects/settings/general/project-settings-general-page.tsx +++ b/apps/web/features/projects/settings/general/project-settings-general-page.tsx @@ -1,56 +1,56 @@ -import { ProjectNameForm } from "./project-name"; -import { ProjectDelete } from "./project-delete"; -import { ProjectSettingsGeneralLayout } from "./project-settings-general-layout"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { ProjectService } from "@/lib/services/project.service"; -import { Effect } from "effect"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { NotFoundError } from "@/lib/effect/errors"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Effect } from 'effect'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { ProjectDelete } from './project-delete'; +import { ProjectNameForm } from './project-name'; +import { ProjectSettingsGeneralLayout } from './project-settings-general-layout'; export async function ProjectSettingsGeneralPage({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string; - projectSlug: string; + organizationSlug: string; + projectSlug: string; }) { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - return { project }; - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + return { project }; + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { project } = data.value; + const { project } = data.value; - return ( - - - - - ); + return ( + + + + + ); } diff --git a/apps/web/features/projects/settings/payment-providers/logos/apple-logo.tsx b/apps/web/features/projects/settings/payment-providers/logos/apple-logo.tsx index 9342c1a07..cc4a545c3 100644 --- a/apps/web/features/projects/settings/payment-providers/logos/apple-logo.tsx +++ b/apps/web/features/projects/settings/payment-providers/logos/apple-logo.tsx @@ -1,19 +1,20 @@ -import { cn } from "@voidhash/ui"; +import { cn } from '@voidhash/ui'; export function AppleLogo({ className }: { className?: string }) { - return ( - - - - ); + return ( + + Apple Logo + + + ); } diff --git a/apps/web/features/projects/settings/payment-providers/logos/stripe-logo.tsx b/apps/web/features/projects/settings/payment-providers/logos/stripe-logo.tsx index 0c8a66f2d..01dd06e1a 100644 --- a/apps/web/features/projects/settings/payment-providers/logos/stripe-logo.tsx +++ b/apps/web/features/projects/settings/payment-providers/logos/stripe-logo.tsx @@ -1,25 +1,26 @@ export function StripeLogo({ className }: { className?: string }) { - return ( - - - - - - - - - - - - ); + return ( + + Stripe Logo + + + + + + + + + + + ); } diff --git a/apps/web/features/projects/settings/payment-providers/payment-provider-detail-configuration.tsx b/apps/web/features/projects/settings/payment-providers/payment-provider-detail-configuration.tsx index 17c42a790..4a0b9965f 100644 --- a/apps/web/features/projects/settings/payment-providers/payment-provider-detail-configuration.tsx +++ b/apps/web/features/projects/settings/payment-providers/payment-provider-detail-configuration.tsx @@ -1,269 +1,271 @@ -"use client"; +'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { PaymentProviderConfiguration, Project } from '@voidhash/db'; import { - deletePaymentProviderConfigurationAction, - updatePaymentProviderConfigurationAction, -} from "@/lib/nextjs/server-actions"; -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { zodResolver } from "@hookform/resolvers/zod"; -import type { Project, PaymentProviderConfiguration } from "@voidhash/db"; + Badge, + Button, + Card, + CopyText, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Dropzone, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Label, + useConfirmDialog +} from '@voidhash/ui'; +import { CheckCircleIcon, EllipsisVerticalIcon, XIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { Fragment, useState } from 'react'; +import { type SubmitErrorHandler, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; import { - Form, - Label, - FormField, - FormItem, - FormLabel, - FormControl, - Input, - FormMessage, - CopyText, - Button, - Card, - Dropzone, - useConfirmDialog, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - Badge, -} from "@voidhash/ui"; -import { CheckCircleIcon, EllipsisVerticalIcon, XIcon } from "lucide-react"; -import { useAction } from "next-safe-action/hooks"; -import { useRouter } from "next/navigation"; -import { Fragment, useState } from "react"; -import { SubmitErrorHandler, useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; -import { PaymentProviderLogo } from "./payment-provider-logo"; + deletePaymentProviderConfigurationAction, + updatePaymentProviderConfigurationAction +} from '@/lib/nextjs/server-actions'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; +import { PaymentProviderLogo } from './payment-provider-logo'; export function PaymentProviderDetailConfiguration({ - organizationSlug, - projectSlug, - project, - paymentProviderConfiguration, + organizationSlug, + projectSlug, + project, + paymentProviderConfiguration }: { - organizationSlug: string; - projectSlug: string; - project: Project; - paymentProviderConfiguration: PaymentProviderConfiguration; + organizationSlug: string; + projectSlug: string; + project: Project; + paymentProviderConfiguration: PaymentProviderConfiguration; }) { - const router = useRouter(); + const router = useRouter(); - const paymentProvider = paymentProviders.find( - (pp) => pp.getId() === paymentProviderConfiguration.providerId - )!; - const [name, setName] = useState(paymentProviderConfiguration.name); + const paymentProvider = + paymentProviders.find( + (pp) => pp.getId() === paymentProviderConfiguration.providerId + ) ?? paymentProviders[0]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const form = useForm({ - resolver: zodResolver(paymentProvider?.getGlobalConfigurationSchema()), - defaultValues: paymentProviderConfiguration.configuration, - }); + const [name, setName] = useState(paymentProviderConfiguration.name); - const { execute, isPending } = useAction( - updatePaymentProviderConfigurationAction, - { - onSuccess: () => { - toast.success( - `${paymentProvider?.getTitle()} configuration saved successfully` - ); + // biome-ignore lint/suspicious/noExplicitAny: zod + const form = useForm({ + resolver: zodResolver(paymentProvider?.getGlobalConfigurationSchema()), + defaultValues: paymentProviderConfiguration.configuration + }); - router.refresh(); - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to save ${paymentProvider?.getTitle()} configuration. Please try again.` - ); - }, - } - ); + const { execute, isPending } = useAction( + updatePaymentProviderConfigurationAction, + { + onSuccess: () => { + toast.success( + `${paymentProvider?.getTitle()} configuration saved successfully` + ); - // Delete payment provider configuration - const { ConfirmationDialog, openDialog } = useConfirmDialog(); - const { execute: deletePaymentProviderConfiguration, isPending: isDeleting } = - useAction(deletePaymentProviderConfigurationAction, { - onSuccess: () => { - toast.success( - `${paymentProvider?.getTitle()} configuration deleted successfully` - ); - router.push( - `/${organizationSlug}/${projectSlug}/settings/payment-providers` - ); - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to delete ${paymentProvider?.getTitle()} configuration. Please try again.` - ); - }, - }); + router.refresh(); + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + `Failed to save ${paymentProvider?.getTitle()} configuration. Please try again.` + ); + } + } + ); - const handleDeletePaymentProviderConfiguration = async (id: string) => { - const res = await openDialog({ - title: "Delete payment provider", - description: `Are you sure you want to delete this payment provider?`, - }); + // Delete payment provider configuration + const { ConfirmationDialog, openDialog } = useConfirmDialog(); + const { execute: deletePaymentProviderConfiguration, isPending: isDeleting } = + useAction(deletePaymentProviderConfigurationAction, { + onSuccess: () => { + toast.success( + `${paymentProvider?.getTitle()} configuration deleted successfully` + ); + router.push( + `/${organizationSlug}/${projectSlug}/settings/payment-providers` + ); + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + `Failed to delete ${paymentProvider?.getTitle()} configuration. Please try again.` + ); + } + }); - if (!res) { - return; - } + const handleDeletePaymentProviderConfiguration = async (id: string) => { + const res = await openDialog({ + title: 'Delete payment provider', + description: 'Are you sure you want to delete this payment provider?' + }); - deletePaymentProviderConfiguration({ - paymentProviderConfigurationId: id, - }); - }; + if (!res) { + return; + } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const onValidSubmit = async ( - data: z.infer< - ReturnType - > - ) => { - execute({ - id: paymentProviderConfiguration.id, - enabled: paymentProviderConfiguration.enabled, - name: name, - configuration: data, - }); - }; + deletePaymentProviderConfiguration({ + paymentProviderConfigurationId: id + }); + }; - const onInvalidSubmit: SubmitErrorHandler< - z.infer> - > = (errors) => { - // Log validation errors for debugging - console.error("Form validation errors:", errors); - }; + const onValidSubmit = ( + data: z.infer< + ReturnType + > + ) => { + execute({ + id: paymentProviderConfiguration.id, + enabled: paymentProviderConfiguration.enabled, + name, + configuration: data + }); + }; - if (!paymentProvider) { - return null; - } + const onInvalidSubmit: SubmitErrorHandler< + z.infer> + > = (errors) => { + // Log validation errors for debugging + // biome-ignore lint/suspicious/noConsole: error handling + console.error('Form validation errors:', errors); + }; - if (!project) { - return null; - } + if (!paymentProvider) { + return null; + } - const configurationSheet = paymentProvider.getGlobalConfigurationSheet({ - projectId: project.id, - }); + if (!project) { + return null; + } - const handleP8FileChange = (name: string, file: File) => { - const reader = new FileReader(); - reader.onload = (e) => { - const content = e.target?.result as string; - form.setValue(name, content); - }; - reader.readAsText(file); - }; + const configurationSheet = paymentProvider.getGlobalConfigurationSheet({ + projectId: project.id + }); - const handleSubmit: (e?: React.BaseSyntheticEvent) => Promise = async ( - e - ) => { - e?.preventDefault(); + const handleP8FileChange = (name: string, file: File) => { + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + form.setValue(name, content); + }; + reader.readAsText(file); + }; - const isValid = await form.trigger(); - const isCurrentlyEnabled = paymentProviderConfiguration.enabled; + const handleSubmit: (e?: React.BaseSyntheticEvent) => Promise = async ( + e + ) => { + e?.preventDefault(); - // Case 1: Form is invalid and provider is currently enabled - if (!isValid && isCurrentlyEnabled) { - const shouldContinue = await openDialog({ - title: "Invalid Configuration", - description: - "The current configuration is invalid. If you continue, the payment provider will be disabled. Do you want to proceed?", - }); + const isValid = await form.trigger(); + const isCurrentlyEnabled = paymentProviderConfiguration.enabled; - if (!shouldContinue) { - return; - } + // Case 1: Form is invalid and provider is currently enabled + if (!isValid && isCurrentlyEnabled) { + const shouldContinue = await openDialog({ + title: 'Invalid Configuration', + description: + 'The current configuration is invalid. If you continue, the payment provider will be disabled. Do you want to proceed?' + }); - // Submit with enabled set to false - await form.handleSubmit((data) => - execute({ - id: paymentProviderConfiguration.id, - enabled: false, - name: name, - configuration: data, - }) - )(e); - return; - } + if (!shouldContinue) { + return; + } - // Case 2: Form is valid and provider is currently disabled - if (isValid && !isCurrentlyEnabled) { - const shouldEnable = (await openDialog({ - title: "Enable Payment Provider", - description: "Would you like to enable this payment provider?", - })) as boolean; + // Submit with enabled set to false + await form.handleSubmit((data) => + execute({ + id: paymentProviderConfiguration.id, + enabled: false, + name, + configuration: data + }) + )(e); + return; + } - // Submit with enabled based on user choice - await form.handleSubmit((data) => - execute({ - id: paymentProviderConfiguration.id, - enabled: shouldEnable, - name: name, - configuration: data, - }) - )(e); - return; - } + // Case 2: Form is valid and provider is currently disabled + if (isValid && !isCurrentlyEnabled) { + const shouldEnable = (await openDialog({ + title: 'Enable Payment Provider', + description: 'Would you like to enable this payment provider?' + })) as boolean; - await form.handleSubmit(onValidSubmit, onInvalidSubmit)(e); - }; + // Submit with enabled based on user choice + await form.handleSubmit((data) => + execute({ + id: paymentProviderConfiguration.id, + enabled: shouldEnable, + name, + configuration: data + }) + )(e); + return; + } - return ( -
- -
-
-
-
- - } - className="w-8 h-8" - /> -

- {paymentProviderConfiguration.name} -

- {paymentProviderConfiguration.enabled ? ( - Enabled - ) : ( - Disabled - )} -
-
- - - - - - - { - e.preventDefault(); - handleDeletePaymentProviderConfiguration( - paymentProviderConfiguration.id - ); - }} - > - Delete - - - -
-
+ await form.handleSubmit(onValidSubmit, onInvalidSubmit)(e); + }; - {/* + return ( + + +
+
+
+
+ + } + /> +

+ {paymentProviderConfiguration.name} +

+ {paymentProviderConfiguration.enabled ? ( + Enabled + ) : ( + Disabled + )} +
+
+ + + + + + + { + e.preventDefault(); + handleDeletePaymentProviderConfiguration( + paymentProviderConfiguration.id + ); + }} + variant="destructive" + > + Delete + + + +
+
+ + {/*
This provider is correctly configured @@ -272,121 +274,121 @@ export function PaymentProviderDetailConfiguration({
*/} -
-
+
+
-
-
-
-
-

- Configuration -

-
-
-
-
- {form.formState.errors.root && ( -
- {form.formState.errors.root.message} -
- )} -
+
+
+
+
+

+ Configuration +

+
+
+
+
+ {form.formState.errors.root && ( +
+ {form.formState.errors.root.message} +
+ )} +
-
- {paymentProvider.getType() === "native" && ( -
- - setName(e.target.value)} - /> -
- )} +
+ {paymentProvider.getType() === 'native' && ( +
+ + setName(e.target.value)} + value={name} + /> +
+ )} - {configurationSheet?.sections.map((section) => ( - - {section.type === "text-input" && ( - ( - - {section.label} - - - - - - )} - /> - )} - {section.type === "copy-text" && ( -
- -
- -
-
- )} - {section.type === "p8-upload" && ( - ( - - Private Key (.p8 file) - - {field.value ? ( - -
- -

- Private key was successfully attached -

-
- -
- ) : ( - - handleP8FileChange(section.name, file) - } - accept=".p8" - maxSize={1024 * 1024} // 1MB - /> - )} -
- -
- )} - /> - )} -
- ))} -
-
-
-
-
+ {configurationSheet?.sections.map((section) => ( + + {section.type === 'text-input' && ( + ( + + {section.label} + + + + + + )} + /> + )} + {section.type === 'copy-text' && ( +
+ +
+ +
+
+ )} + {section.type === 'p8-upload' && ( + ( + + Private Key (.p8 file) + + {field.value ? ( + +
+ +

+ Private key was successfully attached +

+
+ +
+ ) : ( + + handleP8FileChange(section.name, file) + } // 1MB + /> + )} +
+ +
+ )} + /> + )} +
+ ))} +
+
+
+
+
- - - - ); + + + + ); } diff --git a/apps/web/features/projects/settings/payment-providers/payment-provider-detail-page.tsx b/apps/web/features/projects/settings/payment-providers/payment-provider-detail-page.tsx index 2c4521d18..f7f5b89d1 100644 --- a/apps/web/features/projects/settings/payment-providers/payment-provider-detail-page.tsx +++ b/apps/web/features/projects/settings/payment-providers/payment-provider-detail-page.tsx @@ -1,82 +1,82 @@ -import { Page } from "@/features/shell"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { PaymentProviderDetailConfiguration } from "./payment-provider-detail-configuration"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { PaymentProviderService } from "@/lib/services/payment-provider.service"; -import { ProjectService } from "@/lib/services/project.service"; -import { Effect } from "effect"; -import { NotFoundError } from "@/lib/effect/errors"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Effect } from 'effect'; +import { Page } from '@/features/shell'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { PaymentProviderService } from '@/lib/services/payment-provider.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { PaymentProviderDetailConfiguration } from './payment-provider-detail-configuration'; export async function PaymentProviderDetailPage({ - paramsPromise, + paramsPromise }: { - paramsPromise: Promise<{ - paymentProviderConfigurationId: string; - organizationSlug: string; - projectSlug: string; - }>; + paramsPromise: Promise<{ + paymentProviderConfigurationId: string; + organizationSlug: string; + projectSlug: string; + }>; }) { - const { organizationSlug, projectSlug, paymentProviderConfigurationId } = - await paramsPromise; + const { organizationSlug, projectSlug, paymentProviderConfigurationId } = + await paramsPromise; - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const paymentProviderService = yield* PaymentProviderService; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - const paymentProviderConfiguration = - yield* paymentProviderService.getPaymentProviderConfigurationById( - paymentProviderConfigurationId - ); - return { project, paymentProviderConfiguration }; - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const paymentProviderService = yield* PaymentProviderService; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + const paymentProviderConfiguration = + yield* paymentProviderService.getPaymentProviderConfigurationById( + paymentProviderConfigurationId + ); + return { project, paymentProviderConfiguration }; + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { project, paymentProviderConfiguration } = data.value; + const { project, paymentProviderConfiguration } = data.value; - return ( - - - - ); + return ( + + + + ); } diff --git a/apps/web/features/projects/settings/payment-providers/payment-provider-logo.tsx b/apps/web/features/projects/settings/payment-providers/payment-provider-logo.tsx index 00b3daf5b..5a39a01ea 100644 --- a/apps/web/features/projects/settings/payment-providers/payment-provider-logo.tsx +++ b/apps/web/features/projects/settings/payment-providers/payment-provider-logo.tsx @@ -1,39 +1,39 @@ -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { AppleLogo } from "./logos/apple-logo"; -import { StripeLogo } from "./logos/stripe-logo"; -import { cn, Logo } from "@voidhash/ui"; +import { cn, Logo } from '@voidhash/ui'; +import type { paymentProviders } from '@/lib/payment-providers/payment-providers'; +import { AppleLogo } from './logos/apple-logo'; +import { StripeLogo } from './logos/stripe-logo'; export function PaymentProviderLogo({ - providerId, - className, + providerId, + className }: { - providerId: ReturnType<(typeof paymentProviders)[number]["getId"]>; - className?: string; + providerId: ReturnType<(typeof paymentProviders)[number]['getId']>; + className?: string; }) { - if (providerId === "app-store") { - return ; - } + if (providerId === 'app-store') { + return ; + } - if (providerId === "stripe") { - return ; - } + if (providerId === 'stripe') { + return ; + } - if (providerId === "dev-checkout") { - return ( -
- -
- ); - } + if (providerId === 'dev-checkout') { + return ( +
+ +
+ ); + } - return null; + return null; } diff --git a/apps/web/features/projects/settings/payment-providers/payment-providers-new-store-dropdown.tsx b/apps/web/features/projects/settings/payment-providers/payment-providers-new-store-dropdown.tsx index 3cf08c9b3..68cb35f4f 100644 --- a/apps/web/features/projects/settings/payment-providers/payment-providers-new-store-dropdown.tsx +++ b/apps/web/features/projects/settings/payment-providers/payment-providers-new-store-dropdown.tsx @@ -1,77 +1,73 @@ -"use client"; +'use client'; +import type { Project } from '@voidhash/db'; import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@voidhash/ui"; -import { PlusIcon } from "lucide-react"; -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import type { Project } from "@voidhash/db"; -import { useAction } from "next-safe-action/hooks"; -import { toast } from "sonner"; -import { createPaymentProviderConfigurationAction } from "@/lib/nextjs/server-actions"; -import { useRouter } from "next/navigation"; + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@voidhash/ui'; +import { PlusIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { toast } from 'sonner'; +import { createPaymentProviderConfigurationAction } from '@/lib/nextjs/server-actions'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; export function PaymentProvidersNewStoreDropdown({ - project, - organizationSlug, - projectSlug, + project, + organizationSlug, + projectSlug }: { - project: Project; - organizationSlug: string; - projectSlug: string; + project: Project; + organizationSlug: string; + projectSlug: string; }) { - const router = useRouter(); + const router = useRouter(); - const { execute, isPending } = useAction( - createPaymentProviderConfigurationAction, - { - onSuccess: (res) => { - toast.success("Payment provider configuration created successfully"); - router.push( - `/${organizationSlug}/${projectSlug}/settings/payment-providers/${res.data?.id}` - ); - }, - } - ); + const { execute, isPending } = useAction( + createPaymentProviderConfigurationAction, + { + onSuccess: (res) => { + toast.success('Payment provider configuration created successfully'); + router.push( + `/${organizationSlug}/${projectSlug}/settings/payment-providers/${res.data?.id}` + ); + } + } + ); - const handleCreatePaymentProviderConfiguration = async ( - providerId: string - ) => { - execute({ - providerId, - projectId: project.id, - }); - }; + const handleCreatePaymentProviderConfiguration = (providerId: string) => { + execute({ + providerId, + projectId: project.id + }); + }; - return ( - <> - - - - - - {paymentProviders - .filter((p) => p.getType() === "native") - .map((p) => ( - { - handleCreatePaymentProviderConfiguration(p.getId()); - }} - > - {p.getTitle()} - - ))} - - - - ); + return ( + + + + + + {paymentProviders + .filter((p) => p.getType() === 'native') + .map((p) => ( + { + handleCreatePaymentProviderConfiguration(p.getId()); + }} + > + {p.getTitle()} + + ))} + + + ); } diff --git a/apps/web/features/projects/settings/payment-providers/payment-providers-page-skeleton.tsx b/apps/web/features/projects/settings/payment-providers/payment-providers-page-skeleton.tsx index 7515d0104..dd9f3ba83 100644 --- a/apps/web/features/projects/settings/payment-providers/payment-providers-page-skeleton.tsx +++ b/apps/web/features/projects/settings/payment-providers/payment-providers-page-skeleton.tsx @@ -1,40 +1,40 @@ -import { Page } from "@/features/shell"; -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { Card, Skeleton } from "@voidhash/ui"; +import { Card, Skeleton } from '@voidhash/ui'; +import { Page } from '@/features/shell'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; export function PaymentProvidersPageSkeleton() { - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-

- Payment Providers -

-

- Configure your payment providers -

-
- - {paymentProviders?.map((paymentProvider) => ( -
-
-
-
- -
-
- -
-
-
-
- ))} -
-
-
-
- ); + return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+

+ Payment Providers +

+

+ Configure your payment providers +

+
+ + {paymentProviders?.map((paymentProvider) => ( +
+
+
+
+ +
+
+ +
+
+
+
+ ))} +
+
+
+
+ ); } diff --git a/apps/web/features/projects/settings/payment-providers/payment-providers-page.tsx b/apps/web/features/projects/settings/payment-providers/payment-providers-page.tsx index 9d995a845..99dfad97e 100644 --- a/apps/web/features/projects/settings/payment-providers/payment-providers-page.tsx +++ b/apps/web/features/projects/settings/payment-providers/payment-providers-page.tsx @@ -1,173 +1,173 @@ -import { Page } from "@/features/shell"; -import { Badge, Card, CardHeader, CardTitle, cn } from "@voidhash/ui"; -import Link from "next/link"; -import { PaymentProviderLogo } from "./payment-provider-logo"; -import { ChevronRightIcon } from "lucide-react"; +import { Environment as EnvironmentEnum } from '@voidhash/lib/index'; +import { Badge, Card, CardHeader, CardTitle, cn } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { ChevronRightIcon } from 'lucide-react'; +import Link from 'next/link'; +import { Page } from '@/features/shell'; +import { EnvironmentFilterNotification } from '@/features/shell/components/environment-filter-notification'; +import { VoidhashErrorCard } from '@/features/shell/components/voidhash-error-card'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; // import { StripeConfigurationSheet } from "./stripe/stripe-configuration-sheet"; // import { AppStoreConfigurationSheet } from "./app-store/app-store-configuration-sheet"; -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { VoidhashErrorCard } from "@/features/shell/components/voidhash-error-card"; -import { EnvironmentFilterNotification } from "@/features/shell/components/environment-filter-notification"; -import { PaymentProvidersNewStoreDropdown } from "./payment-providers-new-store-dropdown"; -import { SetupPaymentProviderButton } from "./setup-payment-provider-button"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { Effect } from "effect"; -import { ProjectService } from "@/lib/services/project.service"; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; -import { PaymentProviderService } from "@/lib/services/payment-provider.service"; -import { NotFoundError } from "@/lib/effect/errors"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/index"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { PaymentProviderService } from '@/lib/services/payment-provider.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { PaymentProviderLogo } from './payment-provider-logo'; +import { PaymentProvidersNewStoreDropdown } from './payment-providers-new-store-dropdown'; +import { SetupPaymentProviderButton } from './setup-payment-provider-button'; export async function PaymentProvidersPage({ - paramsPromise, + paramsPromise }: { - paramsPromise: Promise<{ - organizationSlug: string; - projectSlug: string; - }>; + paramsPromise: Promise<{ + organizationSlug: string; + projectSlug: string; + }>; }) { - const { organizationSlug, projectSlug } = await paramsPromise; + const { organizationSlug, projectSlug } = await paramsPromise; - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environmentService = yield* EnvironmentService; - const environment = - yield* environmentService.getEnvironmentFromCookie({ - organizationSlug, - projectSlug, - }); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const paymentProviderService = yield* PaymentProviderService; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - const paymentProviderConfigurations = - yield* paymentProviderService.getPaymentProviderConfigurations( - project.id - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environmentService = yield* EnvironmentService; + const environment = + yield* environmentService.getEnvironmentFromCookie({ + organizationSlug, + projectSlug + }); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const paymentProviderService = yield* PaymentProviderService; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + const paymentProviderConfigurations = + yield* paymentProviderService.getPaymentProviderConfigurations( + project.id + ); - return { project, environment, paymentProviderConfigurations }; - }) - ); - }) - ); - }) - ); + return { project, environment, paymentProviderConfigurations }; + }) + ); + }) + ); + }) + ); - if (data.isErr()) { - const error = data._unsafeUnwrapErr(); - return ; - } + if (data.isErr()) { + const error = data._unsafeUnwrapErr(); + return ; + } - const { project, environment, paymentProviderConfigurations } = data.value; + const { project, environment, paymentProviderConfigurations } = data.value; - const applicationsWithConfiguration = paymentProviderConfigurations - .map((p) => { - const paymentProvider = paymentProviders.find( - (pp) => pp.getId() === p.providerId - ); - if (!paymentProvider || paymentProvider.getType() !== "native") { - return null; - } - return { - ...p, - provider: paymentProvider, - }; - }) - .filter(Boolean); + const applicationsWithConfiguration = paymentProviderConfigurations + .map((p) => { + const paymentProvider = paymentProviders.find( + (pp) => pp.getId() === p.providerId + ); + if (!paymentProvider || paymentProvider.getType() !== 'native') { + return null; + } + return { + ...p, + provider: paymentProvider + }; + }) + .filter(Boolean); - const webCheckoutProvidersWithConfigurations = paymentProviders - .filter((p) => p.getType() === "web-checkout" && p.getIsConfigurable()) - .map((paymentProvider) => { - const paymentProvidersConfiguration = paymentProviderConfigurations?.find( - (p) => p.providerId === paymentProvider.getId() - ); - return { - ...paymentProvidersConfiguration, - provider: paymentProvider, - }; - }); + const webCheckoutProvidersWithConfigurations = paymentProviders + .filter((p) => p.getType() === 'web-checkout' && p.getIsConfigurable()) + .map((paymentProvider) => { + const paymentProvidersConfiguration = paymentProviderConfigurations?.find( + (p) => p.providerId === paymentProvider.getId() + ); + return { + ...paymentProvidersConfiguration, + provider: paymentProvider + }; + }); - return ( - - {/* Key is used to reload the default form data when the organization slug changes */} -
-

- Payment Providers -

-

- Configure your payment providers. -

+ return ( + + {/* Key is used to reload the default form data when the organization slug changes */} +
+

+ Payment Providers +

+

+ Configure your payment providers. +

- {environment === EnvironmentEnum.Testing && ( - - )} + {environment === EnvironmentEnum.Testing && ( + + )} -
- - 0 ? "py-3" : "py-6" - )} - > -
- Stores - {applicationsWithConfiguration.length > 0 && ( - - )} -
-
- {applicationsWithConfiguration.length === 0 && ( -
-
- You haven't configured any stores for this project. -
-
- -
-
- )} +
+ + 0 ? 'py-3' : 'py-6' + )} + > +
+ Stores + {applicationsWithConfiguration.length > 0 && ( + + )} +
+
+ {applicationsWithConfiguration.length === 0 && ( +
+
+ You haven't configured any stores for this project. +
+
+ +
+
+ )} - {applicationsWithConfiguration?.map( - (paymentProviderConfiguration) => - !paymentProviderConfiguration?.provider ? null : ( -
- {/* + paymentProviderConfiguration?.provider ? ( +
+ {/* */} - + -
-
-
- -
-
-

{paymentProviderConfiguration.name}

-
-
-
- {paymentProviderConfiguration.enabled && ( - Enabled - )} - -
-
-
- ) - )} - -
+
+
+
+ +
+
+

{paymentProviderConfiguration.name}

+
+
+
+ {paymentProviderConfiguration.enabled && ( + Enabled + )} + +
+
+
+ ) : null + )} +
+
-
- - -
- Web Checkout Providers -
-
- {webCheckoutProvidersWithConfigurations?.map( - (paymentProviderConfiguration) => ( -
- {paymentProviderConfiguration.id && - paymentProviderConfiguration.provider.getIsConfigurable() && ( - - )} +
+ + +
+ Web Checkout Providers +
+
+ {webCheckoutProvidersWithConfigurations?.map( + (paymentProviderConfiguration) => ( +
+ {paymentProviderConfiguration.id && + paymentProviderConfiguration.provider.getIsConfigurable() && ( + + )} -
-
-
- -
-
-

- {paymentProviderConfiguration.provider.getTitle()} -

-
-
+
+
+
+ +
+
+

+ {paymentProviderConfiguration.provider.getTitle()} +

+
+
- {/* If configuration exists, show the enabled/disabled badge and the chevron right */} - {paymentProviderConfiguration.id && ( -
- {paymentProviderConfiguration.enabled ? ( - Enabled - ) : ( - Disabled - )} - -
- )} + {/* If configuration exists, show the enabled/disabled badge and the chevron right */} + {paymentProviderConfiguration.id && ( +
+ {paymentProviderConfiguration.enabled ? ( + Enabled + ) : ( + Disabled + )} + +
+ )} - {/* If configuration does not exist, show the add button */} - {!paymentProviderConfiguration.id && ( -
- -
- )} -
-
- ) - )} - -
-
- - ); + {/* If configuration does not exist, show the add button */} + {!paymentProviderConfiguration.id && ( +
+ +
+ )} +
+
+ ) + )} + +
+
+
+ ); } diff --git a/apps/web/features/projects/settings/payment-providers/setup-payment-provider-button.tsx b/apps/web/features/projects/settings/payment-providers/setup-payment-provider-button.tsx index 17519f43e..0c7404363 100644 --- a/apps/web/features/projects/settings/payment-providers/setup-payment-provider-button.tsx +++ b/apps/web/features/projects/settings/payment-providers/setup-payment-provider-button.tsx @@ -1,69 +1,69 @@ -"use client"; -import { createPaymentProviderConfigurationAction } from "@/lib/nextjs/server-actions"; -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { Button } from "@voidhash/ui"; -import { useAction } from "next-safe-action/hooks"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; +'use client'; +import { Button } from '@voidhash/ui'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { toast } from 'sonner'; +import { createPaymentProviderConfigurationAction } from '@/lib/nextjs/server-actions'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; export function SetupPaymentProviderButton({ - projectId, - providerId, - organizationSlug, - projectSlug, + projectId, + providerId, + organizationSlug, + projectSlug }: { - projectId: string; - providerId: string; - organizationSlug: string; - projectSlug: string; + projectId: string; + providerId: string; + organizationSlug: string; + projectSlug: string; }) { - const router = useRouter(); + const router = useRouter(); - const { execute, isPending } = useAction( - createPaymentProviderConfigurationAction, - { - onSuccess: (res) => { - toast.success( - `${paymentProvider?.getTitle()} configuration saved successfully` - ); + const { execute, isPending } = useAction( + createPaymentProviderConfigurationAction, + { + onSuccess: (res) => { + toast.success( + `${paymentProvider?.getTitle()} configuration saved successfully` + ); - if (res.data?.id && paymentProvider?.getIsConfigurable()) { - router.push( - `/${organizationSlug}/${projectSlug}/settings/payment-providers/${res.data.id}` - ); - } else { - router.refresh(); - } - }, - onError: (error) => { - toast.error( - error.error.serverError ?? - `Failed to save ${paymentProvider?.getTitle()} configuration. Please try again.` - ); - }, - } - ); + if (res.data?.id && paymentProvider?.getIsConfigurable()) { + router.push( + `/${organizationSlug}/${projectSlug}/settings/payment-providers/${res.data.id}` + ); + } else { + router.refresh(); + } + }, + onError: (error) => { + toast.error( + error.error.serverError ?? + `Failed to save ${paymentProvider?.getTitle()} configuration. Please try again.` + ); + } + } + ); - const paymentProvider = paymentProviders.find( - (p) => p.getId() === providerId - ); + const paymentProvider = paymentProviders.find( + (p) => p.getId() === providerId + ); - if (!paymentProvider) { - return null; - } + if (!paymentProvider) { + return null; + } - return ( - - ); + return ( + + ); } diff --git a/apps/web/features/shell/components/3d-illustrations/eclipse/glowing-organic-sphere.tsx b/apps/web/features/shell/components/3d-illustrations/eclipse/glowing-organic-sphere.tsx deleted file mode 100644 index 2e3996076..000000000 --- a/apps/web/features/shell/components/3d-illustrations/eclipse/glowing-organic-sphere.tsx +++ /dev/null @@ -1,415 +0,0 @@ -"use client"; - -import { useRef } from "react"; -import { Canvas, useFrame, extend } from "@react-three/fiber"; -import { EffectComposer, Bloom } from "@react-three/postprocessing"; -import { OrbitControls, shaderMaterial } from "@react-three/drei"; -import { type ShaderMaterial, Color, AdditiveBlending, BackSide } from "three"; -import { ScreenEffect } from "./screen-effect"; - -// Custom shader material for the glowing organic sphere with enhanced fresnel effect -const OrganicSphereMaterial = shaderMaterial( - { - time: 0, - outerColor: new Color(0.98, 0.53, 0.0), // Bright orange-yellow - innerColor: new Color(0.42, 0.24, 0.01), // Darker orange-brown - noiseScale: 0.2, - noiseIntensity: 0.29, - pulseSpeed: 1.7, - glowIntensity: 2.7, - fresnelPower: 3.0, // Control the power of the fresnel effect - fresnelIntensity: 1.5, // Control the intensity of the fresnel effect - }, - // Vertex shader - ` - uniform float time; - uniform float noiseScale; - uniform float noiseIntensity; - uniform float pulseSpeed; - varying vec3 vPosition; - varying vec3 vNormal; - varying vec2 vUv; - varying vec3 vViewDirection; - - // Simplex 3D noise function - vec4 permute(vec4 x) { return mod(((x*34.0)+1.0)*x, 289.0); } - vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } - float snoise(vec3 v) { - const vec2 C = vec2(1.0/6.0, 1.0/3.0); - const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); - - // First corner - vec3 i = floor(v + dot(v, C.yyy)); - vec3 x0 = v - i + dot(i, C.xxx); - - // Other corners - vec3 g = step(x0.yzx, x0.xyz); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - - vec3 x1 = x0 - i1 + C.xxx; - vec3 x2 = x0 - i2 + C.yyy; - vec3 x3 = x0 - D.yyy; - - // Permutations - i = mod(i, 289.0); - vec4 p = permute(permute(permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) - + i.y + vec4(0.0, i1.y, i2.y, 1.0)) - + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - - // Gradients - float n_ = 1.0/7.0; // N=7 - vec3 ns = n_ * D.wyz - D.xzx; - - vec4 j = p - 49.0 * floor(p * ns.z *ns.z); - - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - - vec4 x = x_ *ns.x + ns.yyyy; - vec4 y = y_ *ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - - vec4 s0 = floor(b0)*2.0 + 1.0; - vec4 s1 = floor(b1)*2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0)); - - vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww; - - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - - // Normalise gradients - vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3))); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - // Mix final noise value - vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - m = m * m; - return 42.0 * dot(m*m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); - } - - // FBM (Fractal Brownian Motion) for more complex noise - float fbm(vec3 p) { - float value = 0.0; - float amplitude = 0.5; - float frequency = 1.0; - - // Add multiple layers of noise - for (int i = 0; i < 5; i++) { - value += amplitude * snoise(p * frequency); - amplitude *= 0.5; - frequency *= 2.0; - } - - return value; - } - - void main() { - vUv = uv; - vNormal = normal; - vPosition = position; - - // Calculate view direction for fresnel in vertex shader - vViewDirection = normalize(cameraPosition - position); - - // Create organic distortion based on noise - float noise = fbm(position * noiseScale + vec3(0.0, 0.0, time * 0.1)); - - // Pulsating effect - float pulse = 0.05 * sin(time * pulseSpeed * 0.5); - - // Apply distortion to the vertex position - vec3 newPosition = position; - float distortion = noise * noiseIntensity + pulse; - - // For non-spherical shapes, apply distortion more carefully - // Use the normal direction but scale based on distance from center - float distanceFromCenter = length(position); - vec3 normalizedPos = normalize(position); - - // Apply distortion along the normal direction, scaled by distance - newPosition += normal * distortion * (0.5 + 0.5 * distanceFromCenter); - - gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0); - } - `, - // Fragment shader - ` - uniform float time; - uniform vec3 outerColor; - uniform vec3 innerColor; - uniform float glowIntensity; - uniform float pulseSpeed; - uniform float fresnelPower; - uniform float fresnelIntensity; - - varying vec3 vPosition; - varying vec3 vNormal; - varying vec2 vUv; - varying vec3 vViewDirection; - - // Simplex 3D noise function (same as in vertex shader) - vec4 permute(vec4 x) { return mod(((x*34.0)+1.0)*x, 289.0); } - vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } - float snoise(vec3 v) { - const vec2 C = vec2(1.0/6.0, 1.0/3.0); - const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); - - // First corner - vec3 i = floor(v + dot(v, C.yyy)); - vec3 x0 = v - i + dot(i, C.xxx); - - // Other corners - vec3 g = step(x0.yzx, x0.xyz); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - - vec3 x1 = x0 - i1 + C.xxx; - vec3 x2 = x0 - i2 + C.yyy; - vec3 x3 = x0 - D.yyy; - - // Permutations - i = mod(i, 289.0); - vec4 p = permute(permute(permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) - + i.y + vec4(0.0, i1.y, i2.y, 1.0)) - + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - - // Gradients - float n_ = 1.0/7.0; // N=7 - vec3 ns = n_ * D.wyz - D.xzx; - - vec4 j = p - 49.0 * floor(p * ns.z *ns.z); - - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - - vec4 x = x_ *ns.x + ns.yyyy; - vec4 y = y_ *ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - - vec4 s0 = floor(b0)*2.0 + 1.0; - vec4 s1 = floor(b1)*2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0)); - - vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww; - - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - - // Normalise gradients - vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3))); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - // Mix final noise value - vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - m = m * m; - return 42.0 * dot(m*m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); - } - - float fbm(vec3 p) { - float value = 0.0; - float amplitude = 0.5; - float frequency = 1.0; - - // Add multiple layers of noise - for (int i = 0; i < 5; i++) { - value += amplitude * snoise(p * frequency); - amplitude *= 0.5; - frequency *= 2.0; - } - - return value; - } - - void main() { - // Enhanced fresnel effect for edge glow - float fresnel = pow(1.0 - max(0.0, dot(vViewDirection, normalize(vNormal))), fresnelPower) * fresnelIntensity; - - // Create pulsating effect - float pulse = 0.5 + 0.5 * sin(time * pulseSpeed * 0.5); - - // Mix colors based on fresnel and pulse - vec3 color = mix(innerColor, outerColor, fresnel * (0.8 + 0.2 * pulse)); - - // Apply glow intensity with enhanced fresnel - float intensity = (fresnel * 0.8 + 0.2) * glowIntensity; - - // Set final color with glow - gl_FragColor = vec4(color, intensity); - } - ` -); - -// Extend Three.js with our custom material -extend({ OrganicSphereMaterial }); - -// Function to render the appropriate geometry -function renderGeometry(geometry: string, segments = 64) { - switch (geometry) { - case "cube": - return ; - case "cone": - return ; - case "cylinder": - return ; - case "torus": - return ; - case "octahedron": - return ; - case "dodecahedron": - return ; - case "icosahedron": - return ; - default: - return ; - } -} - -// Inner glowing core component with configurable geometry -function InnerCore({ geometry }: { geometry: string }) { - const materialRef = useRef(null); - - const innerColor = "#690d52"; - const noiseScale = 0.2; - const noiseIntensity = 0.17; - const pulseSpeed = 0.7; - const glowIntensity = 1.3; - const fresnelPower = 2.6; - const fresnelIntensity = 1.5; - - useFrame((state) => { - if (materialRef.current) { - materialRef.current.uniforms.time!.value = state.clock.getElapsedTime(); - materialRef.current.uniforms.innerColor!.value = new Color(innerColor); - materialRef.current.uniforms.outerColor!.value = new Color("#ff8800"); - materialRef.current.uniforms.noiseScale!.value = noiseScale; - materialRef.current.uniforms.noiseIntensity!.value = noiseIntensity; - materialRef.current.uniforms.pulseSpeed!.value = pulseSpeed; - materialRef.current.uniforms.glowIntensity!.value = glowIntensity; - materialRef.current.uniforms.fresnelPower!.value = fresnelPower; - materialRef.current.uniforms.fresnelIntensity!.value = fresnelIntensity; - } - }); - - return ( - - {renderGeometry(geometry, 64)} - {/* @ts-expect-error - custom material */} - - - ); -} - -// Outer glow component with matching geometry -function OuterGlow({ geometry }: { geometry: string }) { - const materialRef = useRef(null); - - const outerColor = "#000000"; - const glowSize = 1.2; - const glowIntensity = 0.23; - - useFrame((state) => { - if (materialRef.current) { - materialRef.current.uniforms.time!.value = state.clock.getElapsedTime(); - materialRef.current.uniforms.outerColor!.value = new Color(outerColor); - materialRef.current.uniforms.innerColor!.value = new Color(outerColor); - materialRef.current.uniforms.noiseScale!.value = 0.5; - materialRef.current.uniforms.noiseIntensity!.value = 0.2; - materialRef.current.uniforms.pulseSpeed!.value = 0.3; - materialRef.current.uniforms.glowIntensity!.value = glowIntensity; - materialRef.current.uniforms.fresnelPower!.value = 2.0; - materialRef.current.uniforms.fresnelIntensity!.value = 1.0; - } - }); - - return ( - - {renderGeometry(geometry, 32)} - {/* @ts-expect-error - custom material */} - - - ); -} - -// Main scene component -function Scene() { - const geometry = "sphere"; - - const bloomStrength = 1.2; - const bloomRadius = 1.0; - - const screenDotSize = 5; - const screenIntensity = 1; - const enableScreen = true; - - return ( - <> - - - - - - - - - ); -} - -export default function GlowingOrganicSphere() { - return ( -
- - - - -
- ); -} diff --git a/apps/web/features/shell/components/3d-illustrations/eclipse/screen-effect.tsx b/apps/web/features/shell/components/3d-illustrations/eclipse/screen-effect.tsx deleted file mode 100644 index 0c9d6cc7f..000000000 --- a/apps/web/features/shell/components/3d-illustrations/eclipse/screen-effect.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client"; - -import { useMemo, useRef } from "react"; -import { useFrame, useThree } from "@react-three/fiber"; -import { shaderMaterial } from "@react-three/drei"; -import { extend, type ReactThreeFiber } from "@react-three/fiber"; -import * as THREE from "three"; - -// Create the screen effect shader material -const ScreenMaterial = shaderMaterial( - { - tDiffuse: null, - time: 0, - dotSize: 8.0, - intensity: 0.8, - resolution: new THREE.Vector2(1024, 1024), - }, - // Vertex shader - ` - varying vec2 vUv; - void main() { - vUv = uv; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - } - `, - // Fragment shader - ` - uniform sampler2D tDiffuse; - uniform float time; - uniform float dotSize; - uniform float intensity; - uniform vec2 resolution; - varying vec2 vUv; - - void main() { - vec4 color = texture2D(tDiffuse, vUv); - - // Calculate screen coordinates - vec2 screenPos = vUv * resolution; - - // Create halftone pattern - vec2 dotPos = mod(screenPos, dotSize); - vec2 dotCenter = vec2(dotSize * 0.5); - float dist = distance(dotPos, dotCenter); - - // Calculate brightness of the original pixel - float brightness = dot(color.rgb, vec3(0.299, 0.587, 0.114)); - - // Create dot pattern based on brightness - float dotRadius = dotSize * 0.4 * (1.0 - brightness); - float dotMask = smoothstep(dotRadius - 1.0, dotRadius + 1.0, dist); - - // Add some animation - float pulse = 0.95 + 0.05 * sin(time * 2.0); - dotMask *= pulse; - - // Mix original color with dot pattern - vec3 finalColor = mix(vec3(0.0), color.rgb, 1.0 - dotMask * intensity); - - gl_FragColor = vec4(finalColor, color.a); - } - ` -); - -// Extend Three.js with our custom material -extend({ ScreenMaterial }); - -// Add TypeScript support -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - interface IntrinsicElements { - // @ts-expect-error - custom material - screenMaterial: ReactThreeFiber.Object3DNode< - THREE.ShaderMaterial, - typeof THREE.ShaderMaterial - >; - } - } -} - -interface ScreenEffectProps { - dotSize: number; - intensity: number; - enabled: boolean; -} - -export function ScreenEffect({ - dotSize, - intensity, - enabled, -}: ScreenEffectProps) { - const materialRef = useRef(null); - const { gl, scene, camera, size } = useThree(); - - // Create render targets - const renderTarget = useMemo(() => { - return new THREE.WebGLRenderTarget(size.width, size.height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBAFormat, - }); - }, [size.width, size.height]); - - // Create a scene for the screen effect - const screenScene = useMemo(() => new THREE.Scene(), []); - const screenCamera = useMemo( - () => new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1), - [] - ); - - // Create fullscreen quad - const screenQuad = useMemo(() => { - const geometry = new THREE.PlaneGeometry(2, 2); - return geometry; - }, []); - - useFrame(() => { - if (!enabled || !materialRef.current) return; - - // Render the main scene to the render target - const originalRenderTarget = gl.getRenderTarget(); - gl.setRenderTarget(renderTarget); - gl.render(scene, camera); - gl.setRenderTarget(originalRenderTarget); - - // Update material uniforms - materialRef.current.uniforms.tDiffuse!.value = renderTarget.texture; - materialRef.current.uniforms.time!.value = performance.now() * 0.001; - materialRef.current.uniforms.dotSize!.value = dotSize; - materialRef.current.uniforms.intensity!.value = intensity; - materialRef.current.uniforms.resolution!.value.set(size.width, size.height); - - // Render the screen effect - gl.render(screenScene, screenCamera); - }, 1); // Render after the main scene - - return ( - - - {/* @ts-expect-error - custom material */} - - - - ); -} diff --git a/apps/web/features/shell/components/dashboard-sidebar/index.ts b/apps/web/features/shell/components/dashboard-sidebar/index.ts index 64301c73d..15acf7dff 100644 --- a/apps/web/features/shell/components/dashboard-sidebar/index.ts +++ b/apps/web/features/shell/components/dashboard-sidebar/index.ts @@ -1 +1 @@ -export * from "./dashboard-sidebar-provider"; +export * from './dashboard-sidebar-provider'; diff --git a/apps/web/features/shell/components/environment-filter-notification.tsx b/apps/web/features/shell/components/environment-filter-notification.tsx index c92f49643..2d02a29ee 100644 --- a/apps/web/features/shell/components/environment-filter-notification.tsx +++ b/apps/web/features/shell/components/environment-filter-notification.tsx @@ -1,26 +1,26 @@ -import { cn } from "@voidhash/ui"; +import { cn } from '@voidhash/ui'; export const EnvironmentFilterNotification = ({ - message = "You are using test data.", - className, - type, + message = 'You are using test data.', + className, + type }: { - message: string; - className?: string; - type: "testing" | "shared"; + message: string; + className?: string; + type: 'testing' | 'shared'; }) => { - return ( -
-
{message}
-
- ); + return ( +
+
{message}
+
+ ); }; diff --git a/apps/web/features/shell/components/index.ts b/apps/web/features/shell/components/index.ts index 8c7afe0ff..f59e6412b 100644 --- a/apps/web/features/shell/components/index.ts +++ b/apps/web/features/shell/components/index.ts @@ -1,2 +1,2 @@ -export * from "./dashboard-sidebar"; -export * from "./nav-bar"; +export * from './dashboard-sidebar'; +export * from './nav-bar'; diff --git a/apps/web/features/shell/components/nav-bar/index.ts b/apps/web/features/shell/components/nav-bar/index.ts index 12bcdb06a..db972bb60 100644 --- a/apps/web/features/shell/components/nav-bar/index.ts +++ b/apps/web/features/shell/components/nav-bar/index.ts @@ -1,8 +1,8 @@ -export * from "./nav-user/nav-user"; -export * from "./organization-project-switcher"; -export * from "./nav-bar"; -export * from "./nav-bar-logo"; -export * from "./nav-slash-separator"; -export * from "./project-switcher"; -export * from "./organization-switcher"; -export * from "./organization-project-switcher"; +export * from './nav-bar'; +export * from './nav-bar-logo'; +export * from './nav-slash-separator'; +export * from './nav-user/nav-user'; +export * from './organization-project-switcher'; +export * from './organization-project-switcher'; +export * from './organization-switcher'; +export * from './project-switcher'; diff --git a/apps/web/features/shell/components/nav-bar/nav-bar-logo.tsx b/apps/web/features/shell/components/nav-bar/nav-bar-logo.tsx index 3fc17a178..d1ff5617e 100644 --- a/apps/web/features/shell/components/nav-bar/nav-bar-logo.tsx +++ b/apps/web/features/shell/components/nav-bar/nav-bar-logo.tsx @@ -1,32 +1,32 @@ -import { Logo } from "@voidhash/ui"; -import Link from "next/link"; +import { Logo } from '@voidhash/ui'; +import Link from 'next/link'; export function NavBarLogo({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string | null; - projectSlug: string | null; + organizationSlug: string | null; + projectSlug: string | null; }) { - const homeLink = (() => { - if (organizationSlug && !projectSlug) { - return { - href: `/${organizationSlug}`, - } as const; - } - if (organizationSlug && projectSlug) { - return { - href: `/${organizationSlug}/${projectSlug}`, - } as const; - } - return { - href: "/", - } as const; - })(); + const homeLink = (() => { + if (organizationSlug && !projectSlug) { + return { + href: `/${organizationSlug}` + } as const; + } + if (organizationSlug && projectSlug) { + return { + href: `/${organizationSlug}/${projectSlug}` + } as const; + } + return { + href: '/' + } as const; + })(); - return ( - - - - ); + return ( + + + + ); } diff --git a/apps/web/features/shell/components/nav-bar/nav-bar.tsx b/apps/web/features/shell/components/nav-bar/nav-bar.tsx index ed1c6f920..df58a5ef7 100644 --- a/apps/web/features/shell/components/nav-bar/nav-bar.tsx +++ b/apps/web/features/shell/components/nav-bar/nav-bar.tsx @@ -1,44 +1,47 @@ -import { NavBarLogo } from "./nav-bar-logo"; -import { OrganizationSwitcher } from "./organization-switcher"; -import { NavUser } from "./nav-user/nav-user"; -import { ProjectSwitcher } from "./project-switcher"; -import { NavProjectEnvironment } from "./nav-project-environment"; -import { EnviromentBar } from "./nav-environment-bar"; -export async function NavBar({ - organizationSlug, - projectSlug, -}: { organizationSlug: string | null; projectSlug: string | null }) { - return ( -
- +import { NavBarLogo } from './nav-bar-logo'; +import { EnviromentBar } from './nav-environment-bar'; +import { NavProjectEnvironment } from './nav-project-environment'; +import { NavUser } from './nav-user/nav-user'; +import { OrganizationSwitcher } from './organization-switcher'; +import { ProjectSwitcher } from './project-switcher'; +export function NavBar({ + organizationSlug, + projectSlug +}: { + organizationSlug: string | null; + projectSlug: string | null; +}) { + return ( +
+ -
-
- - {/* */} -
- - -
-
+
+
+ + {/* */} +
+ + +
+
-
- - -
-
-
- ); +
+ + +
+
+
+ ); } diff --git a/apps/web/features/shell/components/nav-bar/nav-environment-bar.tsx b/apps/web/features/shell/components/nav-bar/nav-environment-bar.tsx index d1265bcfa..4730c75b6 100644 --- a/apps/web/features/shell/components/nav-bar/nav-environment-bar.tsx +++ b/apps/web/features/shell/components/nav-bar/nav-environment-bar.tsx @@ -1,95 +1,101 @@ -import { Suspense } from "react"; -import { cn } from "@voidhash/ui"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; +import { Environment as EnvironmentEnum } from '@voidhash/lib/index'; +import { cn } from '@voidhash/ui'; +import { Effect } from 'effect'; +import { Suspense } from 'react'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; -import { ProjectService } from "@/lib/services/project.service"; -import { Effect } from "effect"; -import { NotFoundError } from "@/lib/effect/errors"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/index"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { ProjectService } from '@/lib/services/project.service'; export async function EnviromentBarContent({ - organizationSlug, - projectSlug, -}: { organizationSlug: string | null; projectSlug: string | null }) { - if (!organizationSlug || !projectSlug) { - return null; - } + organizationSlug, + projectSlug +}: { + organizationSlug: string | null; + projectSlug: string | null; +}) { + if (!(organizationSlug && projectSlug)) { + return null; + } - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - organizationSlug, - projectSlug, - }); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const environment = yield* Environment; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - return { project, environment }; - }) - ); - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + organizationSlug, + projectSlug + }); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const environment = yield* Environment; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + return { project, environment }; + }) + ); + }) + ); + }) + ); - if (data.isErr()) { - return null; - } + if (data.isErr()) { + return null; + } - const { project, environment } = data.value; + const { project, environment } = data.value; - const showBar = - project && environment && environment === EnvironmentEnum.Testing; + const showBar = + project && environment && environment === EnvironmentEnum.Testing; - return ( -
- { - // Marker to update layout if bar is visible - showBar && - ); + return ( +
+ { + // Marker to update layout if bar is visible + showBar && + ); } -export async function EnviromentBar({ - organizationSlug, - projectSlug, -}: { organizationSlug: string | null; projectSlug: string | null }) { - return ( -
}> - - - ); +export function EnviromentBar({ + organizationSlug, + projectSlug +}: { + organizationSlug: string | null; + projectSlug: string | null; +}) { + return ( + }> + + + ); } diff --git a/apps/web/features/shell/components/nav-bar/nav-project-environment-toggle.tsx b/apps/web/features/shell/components/nav-bar/nav-project-environment-toggle.tsx index ffb6979e6..27b7d1463 100644 --- a/apps/web/features/shell/components/nav-bar/nav-project-environment-toggle.tsx +++ b/apps/web/features/shell/components/nav-bar/nav-project-environment-toggle.tsx @@ -1,63 +1,66 @@ -"use client"; +'use client'; -import { switchEnvironmentAction } from "@/lib/nextjs/server-actions"; -import { cn, Label, Switch } from "@voidhash/ui"; -import { useAction } from "next-safe-action/hooks"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { - Environment as EnvironmentEnum, - EnvironmentValue, -} from "@voidhash/lib/index"; + Environment as EnvironmentEnum, + type EnvironmentValue +} from '@voidhash/lib/index'; +import { cn, Label, Switch } from '@voidhash/ui'; +import { useRouter } from 'next/navigation'; +import { useAction } from 'next-safe-action/hooks'; +import { toast } from 'sonner'; +import { switchEnvironmentAction } from '@/lib/nextjs/server-actions'; export function NavProjectEnvironmentToggle({ - environment, - projectId, -}: { environment: EnvironmentValue; projectId: string }) { - const router = useRouter(); - const { execute, isExecuting } = useAction(switchEnvironmentAction, { - onSuccess: ({ input }) => { - if (input.environment === EnvironmentEnum.Testing) { - toast.success("Switched to testing environment"); - } else { - toast.success("Switched to production environment"); - } - }, - onError: () => { - toast.error("Failed to switch environment"); - }, - onSettled: () => { - router.refresh(); - }, - }); + environment, + projectId +}: { + environment: EnvironmentValue; + projectId: string; +}) { + const router = useRouter(); + const { execute, isExecuting } = useAction(switchEnvironmentAction, { + onSuccess: ({ input }) => { + if (input.environment === EnvironmentEnum.Testing) { + toast.success('Switched to testing environment'); + } else { + toast.success('Switched to production environment'); + } + }, + onError: () => { + toast.error('Failed to switch environment'); + }, + onSettled: () => { + router.refresh(); + } + }); - const handleSwitch = () => { - execute({ - projectId: projectId, - environment: - environment === EnvironmentEnum.Testing - ? EnvironmentEnum.Production - : EnvironmentEnum.Testing, - }); - }; + const handleSwitch = () => { + execute({ + projectId, + environment: + environment === EnvironmentEnum.Testing + ? EnvironmentEnum.Production + : EnvironmentEnum.Testing + }); + }; - return ( -
- - -
- ); + return ( +
+ + +
+ ); } diff --git a/apps/web/features/shell/components/nav-bar/nav-project-environment.tsx b/apps/web/features/shell/components/nav-bar/nav-project-environment.tsx index c98a591a9..9e86dcee9 100644 --- a/apps/web/features/shell/components/nav-bar/nav-project-environment.tsx +++ b/apps/web/features/shell/components/nav-bar/nav-project-environment.tsx @@ -1,85 +1,91 @@ -import { NavProjectEnvironmentToggle } from "./nav-project-environment-toggle"; -import { Suspense } from "react"; -import { Effect } from "effect"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { ProjectService } from "@/lib/services/project.service"; -import { NotFoundError } from "@/lib/effect/errors"; +import { Environment as EnvironmentEnum } from '@voidhash/lib/index'; +import { Effect } from 'effect'; +import { Suspense } from 'react'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/index"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { NavProjectEnvironmentToggle } from './nav-project-environment-toggle'; export async function NavProjectEnvironmentContent({ - organizationSlug, - projectSlug, -}: { organizationSlug: string | null; projectSlug: string | null }) { - if (!organizationSlug || !projectSlug) { - return null; - } - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environmentService = yield* EnvironmentService; - const environment = - yield* environmentService.getEnvironmentFromCookie({ - organizationSlug, - projectSlug, - }); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const environment = yield* Environment; - const project = - yield* projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }); - if (!project) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }) - ); - } - return { project, environment }; - }) - ); - }) - ); - }) - ); + organizationSlug, + projectSlug +}: { + organizationSlug: string | null; + projectSlug: string | null; +}) { + if (!(organizationSlug && projectSlug)) { + return null; + } + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environmentService = yield* EnvironmentService; + const environment = + yield* environmentService.getEnvironmentFromCookie({ + organizationSlug, + projectSlug + }); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const environment = yield* Environment; + const project = + yield* projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }); + if (!project) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + return { project, environment }; + }) + ); + }) + ); + }) + ); - if (data.isErr()) { - return null; - } + if (data.isErr()) { + return null; + } - const { project, environment } = data.value; + const { project, environment } = data.value; - return ( -
- -
- ); + return ( +
+ +
+ ); } -export async function NavProjectEnvironment({ - organizationSlug, - projectSlug, -}: { organizationSlug: string | null; projectSlug: string | null }) { - return ( -
}> - - - ); +export function NavProjectEnvironment({ + organizationSlug, + projectSlug +}: { + organizationSlug: string | null; + projectSlug: string | null; +}) { + return ( + }> + + + ); } diff --git a/apps/web/features/shell/components/nav-bar/nav-slash-separator.tsx b/apps/web/features/shell/components/nav-bar/nav-slash-separator.tsx index 975cf3ee6..8adeaf018 100644 --- a/apps/web/features/shell/components/nav-bar/nav-slash-separator.tsx +++ b/apps/web/features/shell/components/nav-bar/nav-slash-separator.tsx @@ -1,14 +1,15 @@ -import { cn } from "@voidhash/ui/utils"; +import { cn } from '@voidhash/ui/utils'; export function NavSlashSeparator({ className }: { className?: string }) { - return ( - - - - ); + return ( + + Nav Slash Separator + + + ); } diff --git a/apps/web/features/shell/components/nav-bar/nav-user/nav-user-dropdown.tsx b/apps/web/features/shell/components/nav-bar/nav-user/nav-user-dropdown.tsx index eaf1d381b..e8dc9cedd 100644 --- a/apps/web/features/shell/components/nav-bar/nav-user/nav-user-dropdown.tsx +++ b/apps/web/features/shell/components/nav-bar/nav-user/nav-user-dropdown.tsx @@ -1,64 +1,60 @@ -"use client"; +'use client'; +import { authClient } from '@voidhash/auth/client'; import { - DropdownMenuContent, - DropdownMenuLabel, - Avatar, - GradientAvatar, - AvatarFallback, - DropdownMenuSeparator, - DropdownMenuItem, - ToggleGroup, - ToggleGroupItem, -} from "@voidhash/ui"; -import { LogOut, Monitor, Moon, Sun } from "lucide-react"; -import { authClient } from "@voidhash/auth/client"; -import { useRouter } from "next/navigation"; -import { useTheme } from "next-themes"; -import { User } from "better-auth"; + Avatar, + AvatarFallback, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + GradientAvatar, + ToggleGroup, + ToggleGroupItem +} from '@voidhash/ui'; +import type { User } from 'better-auth'; +import { LogOut, Monitor, Moon, Sun } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useTheme } from 'next-themes'; -export function NavUserDropdown({ - user, -}: { - user: User; -}) { - const router = useRouter(); +export function NavUserDropdown({ user }: { user: User }) { + const router = useRouter(); - const { setTheme, theme } = useTheme(); + const { setTheme, theme } = useTheme(); - const handleSignOut = async () => { - await authClient.signOut(); - router.refresh(); - router.push("/login"); - }; + const handleSignOut = async () => { + await authClient.signOut(); + router.refresh(); + router.push('/login'); + }; - return ( - - -
- - - CN - -
- {user.name} - - {user.email} - -
-
-
- {/* + return ( + + +
+ + + CN + +
+ {user.name} + + {user.email} + +
+
+
+ {/* @@ -70,47 +66,47 @@ export function NavUserDropdown({ Billing */} - -
- Theme -
- setTheme(value)} - > - - - - - - - - - - -
-
- - - - -
- ); + +
+ Theme +
+ setTheme(value)} + type="single" + value={theme} + > + + + + + + + + + + +
+
+ + + + +
+ ); } diff --git a/apps/web/features/shell/components/nav-bar/nav-user/nav-user.tsx b/apps/web/features/shell/components/nav-bar/nav-user/nav-user.tsx index 2716f6780..d33cf62fe 100644 --- a/apps/web/features/shell/components/nav-bar/nav-user/nav-user.tsx +++ b/apps/web/features/shell/components/nav-bar/nav-user/nav-user.tsx @@ -1,62 +1,69 @@ -import { GradientAvatar, Skeleton } from "@voidhash/ui"; -import { DropdownMenu, DropdownMenuTrigger } from "@voidhash/ui"; -import { Suspense } from "react"; -import { NavUserDropdown } from "./nav-user-dropdown"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { UserService } from "@/lib/services/user.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { + DropdownMenu, + DropdownMenuTrigger, + GradientAvatar, + Skeleton +} from '@voidhash/ui'; +import { Effect } from 'effect'; +import { Suspense } from 'react'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { UserService } from '@/lib/services/user.service'; +import { NavUserDropdown } from './nav-user-dropdown'; function NavUserSkeleton() { - return ; + return ; } export async function NavUserContent() { - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const userService = yield* UserService; - const user = yield* userService.getUser(); - return { user }; - }) - ); - }) - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const userService = yield* UserService; + const user = yield* userService.getUser(); + return { user }; + }) + ); + }) + ); - if (data.isErr()) { - return
Error loading user
; - } + if (data.isErr()) { + return
Error loading user
; + } - const { user } = data.value; + const { user } = data.value; - return ( -
- - - - - {user && } - -
- ); + return ( +
+ + + + + {user && } + +
+ ); } -export async function NavUser() { - return ( - }> - - - ); +export function NavUser() { + return ( + }> + + + ); } diff --git a/apps/web/features/shell/components/nav-bar/organization-project-switcher.tsx b/apps/web/features/shell/components/nav-bar/organization-project-switcher.tsx index 9a34ef1fd..7d02ed3ab 100644 --- a/apps/web/features/shell/components/nav-bar/organization-project-switcher.tsx +++ b/apps/web/features/shell/components/nav-bar/organization-project-switcher.tsx @@ -1,201 +1,205 @@ -"use client"; -import * as React from "react"; -import { Check, ChevronsUpDown, Plus } from "lucide-react"; +'use client'; +import { useQuery } from '@tanstack/react-query'; +import type { Organization, Project } from '@voidhash/db'; import { - Button, - Popover, - PopoverContent, - PopoverTrigger, - GradientAvatar, - cn, -} from "@voidhash/ui"; -import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; -import { useTRPC } from "../../../trpc/react"; -import { CreateOrganizationModal } from "../../../organizations/create-organization-modal"; -import { CreateProjectModal } from "../../../projects/create-project-modal"; -import type { Organization, Project } from "@voidhash/db"; + Button, + cn, + GradientAvatar, + Popover, + PopoverContent, + PopoverTrigger +} from '@voidhash/ui'; +import { Check, ChevronsUpDown, Plus } from 'lucide-react'; +import Link from 'next/link'; +import * as React from 'react'; +import { CreateOrganizationModal } from '../../../organizations/create-organization-modal'; +import { CreateProjectModal } from '../../../projects/create-project-modal'; +import { useTRPC } from '../../../trpc/react'; function OrganizationProjectSwitcherProjects({ - organizationId, - organizationSlug, - activeProjectId, - onProjectClick, + organizationId, + organizationSlug, + activeProjectId, + onProjectClick }: { - organizationId: string; - organizationSlug: string; - activeProjectId?: string; - onProjectClick?: () => void; + organizationId: string; + organizationSlug: string; + activeProjectId?: string; + onProjectClick?: () => void; }) { - const trpc = useTRPC(); - const { data } = useQuery( - trpc.projects.getTeamsProjectsBySlug.queryOptions({ - organizationSlug: organizationSlug, - }) - ); - const projects = data ?? []; + const trpc = useTRPC(); + const { data } = useQuery( + trpc.projects.getTeamsProjectsBySlug.queryOptions({ + organizationSlug + }) + ); + const projects = data ?? []; - // Create project modal - const [createProjectModalOpen, setCreateProjectModalOpen] = - React.useState(false); + // Create project modal + const [createProjectModalOpen, setCreateProjectModalOpen] = + React.useState(false); - return ( -
-
Projects
- {(projects ?? []).map((project) => ( - - - {project.name} - {project.id === activeProjectId && ( - - )} - - ))} -
- setCreateProjectModalOpen(false)} - trigger={ - - } - /> -
- ); + return ( +
+
Projects
+ {(projects ?? []).map((project) => ( + + + {project.name} + {project.id === activeProjectId && ( + + )} + + ))} +
+ setCreateProjectModalOpen(false)} + open={createProjectModalOpen} + organizationId={organizationId} + organizationSlug={organizationSlug} + trigger={ + + } + /> +
+ ); } export function OrganizationProjectSwitcher({ - user, - activeProject, - activeOrganization, + user, + activeProject, + activeOrganization }: { - user: { - organizations: Organization[]; - }; - activeProject: Project | null; - activeOrganization: Organization; + user: { + organizations: Organization[]; + }; + activeProject: Project | null; + activeOrganization: Organization; }) { - const [open, setOpen] = React.useState(false); - const me = user; + const [open, setOpen] = React.useState(false); + const me = user; - const organizations = me?.organizations ?? []; + const organizations = me?.organizations ?? []; - // Highlight organization - const [highlightedOrganizationIndex, setHighlightedOrganizationIndex] = - React.useState(null); - const highlightedOrganization = - highlightedOrganizationIndex !== null - ? organizations[highlightedOrganizationIndex] - : null; + // Highlight organization + const [highlightedOrganizationIndex, setHighlightedOrganizationIndex] = + React.useState(null); + const highlightedOrganization = + highlightedOrganizationIndex !== null + ? organizations[highlightedOrganizationIndex] + : null; - const [createOrganizationModalOpen, setCreateOrganizationModalOpen] = - React.useState(false); + const [createOrganizationModalOpen, setCreateOrganizationModalOpen] = + React.useState(false); - return ( - - - - - - {activeOrganization && ( -
setHighlightedOrganizationIndex(null)} - > -
-
- Teams -
- {organizations.map((organization, index) => ( - setOpen(false)} - onMouseEnter={() => setHighlightedOrganizationIndex(index)} - className={cn( - "flex w-full items-center gap-2 p-2 hover:bg-accent/50 text-foreground hover:text-accent-foreground text-sm", - organization.slug === - (highlightedOrganization?.slug ?? - activeOrganization?.slug) && "bg-accent/50" - )} - > - - {organization.name} - {organization.slug === activeOrganization.slug && ( - - )} - - ))} -
- setCreateOrganizationModalOpen(false)} - trigger={ - - } - /> -
- {(highlightedOrganization || activeProject) && ( - setOpen(false)} - /> - )} -
- )} - - - ); + return ( + + + + + + {activeOrganization && ( + // biome-ignore lint/a11y/noStaticElementInteractions: visual effect + // biome-ignore lint/nursery/noNoninteractiveElementInteractions: visual effect +
setHighlightedOrganizationIndex(null)} + > +
+
+ Teams +
+ {organizations.map((organization, index) => ( + setOpen(false)} + onMouseEnter={() => setHighlightedOrganizationIndex(index)} + > + + {organization.name} + {organization.slug === activeOrganization.slug && ( + + )} + + ))} +
+ setCreateOrganizationModalOpen(false)} + open={createOrganizationModalOpen} + trigger={ + + } + /> +
+ {(highlightedOrganization || activeProject) && ( + setOpen(false)} + organizationId={ + highlightedOrganization?.id ?? activeOrganization.id + } + organizationSlug={ + highlightedOrganization?.slug ?? + activeOrganization.slug ?? + '-' + } + /> + )} +
+ )} + + + ); } diff --git a/apps/web/features/shell/components/nav-bar/organization-switcher.tsx b/apps/web/features/shell/components/nav-bar/organization-switcher.tsx index 959df0c29..bd813d4d7 100644 --- a/apps/web/features/shell/components/nav-bar/organization-switcher.tsx +++ b/apps/web/features/shell/components/nav-bar/organization-switcher.tsx @@ -1,107 +1,106 @@ -import { GradientAvatar } from "@voidhash/ui/gradient-avatar"; - -import { OrganizationProjectSwitcher } from "./organization-project-switcher"; -import Link from "next/link"; -import { Suspense } from "react"; -import { Skeleton } from "@voidhash/ui"; -import { OrganizationService } from "@/lib/services/organization.service"; -import { UserService } from "@/lib/services/user.service"; -import { Effect } from "effect"; -import { NotFoundError } from "@/lib/effect/errors"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Skeleton } from '@voidhash/ui'; +import { GradientAvatar } from '@voidhash/ui/gradient-avatar'; +import { Effect } from 'effect'; +import Link from 'next/link'; +import { Suspense } from 'react'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { OrganizationService } from '@/lib/services/organization.service'; +import { UserService } from '@/lib/services/user.service'; +import { OrganizationProjectSwitcher } from './organization-project-switcher'; const OrganizationSwitcherComponent = async ({ - organizationSlug, + organizationSlug }: { - organizationSlug: string | null; + organizationSlug: string | null; }) => { - if (!organizationSlug) { - return null; - } + if (!organizationSlug) { + return null; + } - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const userService = yield* UserService; - const organizationService = yield* OrganizationService; - const [user, activeOrganization] = yield* Effect.all( - [ - userService.getUser(), - organizationService.getOrganizationBySlug(organizationSlug).pipe( - Effect.catchTags({ - OrganizationNotFound: () => - Effect.fail( - new NotFoundError({ - message: "Organization not found", - }), - ), - }), - ), - ], - { - concurrency: "unbounded", - }, - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const userService = yield* UserService; + const organizationService = yield* OrganizationService; + const [user, activeOrganization] = yield* Effect.all( + [ + userService.getUser(), + organizationService.getOrganizationBySlug(organizationSlug).pipe( + Effect.catchTags({ + OrganizationNotFound: () => + Effect.fail( + new NotFoundError({ + message: 'Organization not found' + }) + ) + }) + ) + ], + { + concurrency: 'unbounded' + } + ); - return { user, activeOrganization }; - }), - ); - }), - ); + return { user, activeOrganization }; + }) + ); + }) + ); - if (data.isErr()) { - return null; - } + if (data.isErr()) { + return null; + } - const { user, activeOrganization } = data.value; + const { user, activeOrganization } = data.value; - return ( -
- -
- - - {activeOrganization.name} - -
- - -
- ); + return ( +
+ +
+ + + {activeOrganization.name} + +
+ + +
+ ); }; function OrganizationSwitcherSkeleton() { - return ( -
-
- - -
-
- ); + return ( +
+
+ + +
+
+ ); } -export async function OrganizationSwitcher({ - organizationSlug, +export function OrganizationSwitcher({ + organizationSlug }: { - organizationSlug: string | null; + organizationSlug: string | null; }) { - return ( - }> - - - ); + return ( + }> + + + ); } diff --git a/apps/web/features/shell/components/nav-bar/project-switcher.tsx b/apps/web/features/shell/components/nav-bar/project-switcher.tsx index ac7cebd16..3f325dabe 100644 --- a/apps/web/features/shell/components/nav-bar/project-switcher.tsx +++ b/apps/web/features/shell/components/nav-bar/project-switcher.tsx @@ -1,120 +1,120 @@ -import { GradientAvatar, Skeleton } from "@voidhash/ui"; -import Link from "next/link"; -import { NavSlashSeparator } from "./nav-slash-separator"; -import { OrganizationProjectSwitcher } from "./organization-project-switcher"; -import { Suspense } from "react"; -import { Project } from "@voidhash/db"; -import { UserService } from "@/lib/services/user.service"; -import { Effect } from "effect"; -import { runServerEffect } from "@/lib/effect/runtimes/nextjs"; -import { OrganizationService } from "@/lib/services/organization.service"; -import { ProjectService } from "@/lib/services/project.service"; -import { NotFoundError } from "@/lib/effect/errors"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import type { Project } from '@voidhash/db'; +import { GradientAvatar, Skeleton } from '@voidhash/ui'; +import { Effect } from 'effect'; +import Link from 'next/link'; +import { Suspense } from 'react'; +import { NotFoundError } from '@/lib/effect/errors'; +import { runServerEffect } from '@/lib/effect/runtimes/nextjs'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { OrganizationService } from '@/lib/services/organization.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { UserService } from '@/lib/services/user.service'; +import { NavSlashSeparator } from './nav-slash-separator'; +import { OrganizationProjectSwitcher } from './organization-project-switcher'; -const ProjectTitle = async ({ project }: { project: Project }) => { - return ( -
- +const ProjectTitle = ({ project }: { project: Project }) => { + return ( +
+ - {project.name} -
- ); + {project.name} +
+ ); }; const ProjectTitleSkeleton = () => { - return ( -
- - -
- ); + return ( +
+ + +
+ ); }; export async function ProjectSwitcher({ - organizationSlug, - projectSlug, + organizationSlug, + projectSlug }: { - organizationSlug: string | null; - projectSlug: string | null; + organizationSlug: string | null; + projectSlug: string | null; }) { - if (!projectSlug || !organizationSlug) { - return null; - } + if (!(projectSlug && organizationSlug)) { + return null; + } - const data = await runServerEffect( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const userService = yield* UserService; - const organizationService = yield* OrganizationService; - const projectService = yield* ProjectService; - const [user, activeOrganization, activeProject] = yield* Effect.all( - [ - userService.getUser(), - organizationService.getOrganizationBySlug(organizationSlug).pipe( - Effect.catchTags({ - OrganizationNotFound: () => - Effect.fail( - new NotFoundError({ - message: "Organization not found", - }), - ), - }), - ), - projectService.getProjectBySlugAndOrganizationSlug({ - organizationSlug, - projectSlug, - }), - ], - { - concurrency: "unbounded", - }, - ); + const data = await runServerEffect( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const userService = yield* UserService; + const organizationService = yield* OrganizationService; + const projectService = yield* ProjectService; + const [user, activeOrganization, activeProject] = yield* Effect.all( + [ + userService.getUser(), + organizationService.getOrganizationBySlug(organizationSlug).pipe( + Effect.catchTags({ + OrganizationNotFound: () => + Effect.fail( + new NotFoundError({ + message: 'Organization not found' + }) + ) + }) + ), + projectService.getProjectBySlugAndOrganizationSlug({ + organizationSlug, + projectSlug + }) + ], + { + concurrency: 'unbounded' + } + ); - if (!activeProject) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }), - ); - } - return { user, activeOrganization, activeProject }; - }), - ); - }), - ); + if (!activeProject) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + return { user, activeOrganization, activeProject }; + }) + ); + }) + ); - if (data.isErr()) { - return null; - } + if (data.isErr()) { + return null; + } - const { user, activeOrganization, activeProject } = data.value; + const { user, activeOrganization, activeProject } = data.value; - return ( - <> - -
- -
- }> - - -
- - -
- - ); + return ( + <> + +
+ +
+ }> + + +
+ + +
+ + ); } diff --git a/apps/web/features/shell/components/voidhash-error-card.tsx b/apps/web/features/shell/components/voidhash-error-card.tsx index 162fc6506..828eec527 100644 --- a/apps/web/features/shell/components/voidhash-error-card.tsx +++ b/apps/web/features/shell/components/voidhash-error-card.tsx @@ -1,24 +1,27 @@ -"use client"; +'use client'; -import { NextjsErrorResponse } from "@/lib/effect/runtimes/nextjs"; -import { AnyVoidhashError } from "@voidhash/lib/constants"; -import { ErrorCard } from "@voidhash/ui"; +import type { AnyVoidhashError } from '@voidhash/lib/constants'; +import { ErrorCard } from '@voidhash/ui'; +import type { NextjsErrorResponse } from '@/lib/effect/runtimes/nextjs'; -export function VoidhashErrorCard({ error }: { error: AnyVoidhashError | NextjsErrorResponse }) { - console.error(error); - // TODO: Improve this a lot - return ( - { - window.location.reload(); - }} - /> - ); +export function VoidhashErrorCard({ + error +}: { + error: AnyVoidhashError | NextjsErrorResponse; +}) { + // TODO: Improve this a lot + return ( + { + window.location.reload(); + }} + title="Something went wrong" + /> + ); } diff --git a/apps/web/features/shell/index.ts b/apps/web/features/shell/index.ts index 6efe3b85a..3f2d10706 100644 --- a/apps/web/features/shell/index.ts +++ b/apps/web/features/shell/index.ts @@ -1,4 +1,4 @@ -export * from "./nav-main"; -export * from "./components/nav-bar/nav-slash-separator"; -export * from "./page"; -export * from "./components"; +export * from './components'; +export * from './components/nav-bar/nav-slash-separator'; +export * from './nav-main'; +export * from './page'; diff --git a/apps/web/features/shell/nav-main.tsx b/apps/web/features/shell/nav-main.tsx index 6949b595d..8618c0fb4 100644 --- a/apps/web/features/shell/nav-main.tsx +++ b/apps/web/features/shell/nav-main.tsx @@ -1,108 +1,105 @@ -import { ChevronRight, type LucideIcon } from "lucide-react"; - -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@voidhash/ui"; import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, -} from "@voidhash/ui"; -import NextLink from "next/link"; + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem +} from '@voidhash/ui'; +import { ChevronRight, type LucideIcon } from 'lucide-react'; +import type NextLink from 'next/link'; export function NavMain({ - link: Link, - groups, - tooltips = "enabled", - defaultOpenNested = false, + link: Link, + groups, + tooltips = 'enabled', + defaultOpenNested = false }: { - link: typeof NextLink | "a"; - defaultOpenNested?: boolean; - groups: { - title: string; - items: { - title: string; - url: string; - icon?: LucideIcon; - isActive?: () => boolean; - items?: { - title: string; - url: string; - isActive?: () => boolean; - }[]; - }[]; - }[]; - tooltips?: "enabled" | "disabled"; + link: typeof NextLink | 'a'; + defaultOpenNested?: boolean; + groups: { + title: string; + items: { + title: string; + url: string; + icon?: LucideIcon; + isActive?: () => boolean; + items?: { + title: string; + url: string; + isActive?: () => boolean; + }[]; + }[]; + }[]; + tooltips?: 'enabled' | 'disabled'; }) { - return ( - <> - {groups.map((group) => ( - - {group.title} - - {group.items.map((item) => ( - - - - - {item.icon && ( - - )} - {item.title} - - - {item.items?.length ? ( - <> - - - - Toggle - - - - - {item.items?.map((subItem) => ( - - - - {subItem.title} - - - - ))} - - - - ) : null} - - - ))} - - - ))} - - ); + return ( + <> + {groups.map((group) => ( + + {group.title} + + {group.items.map((item) => ( + + + + + {item.icon && ( + + )} + {item.title} + + + {item.items?.length ? ( + <> + + + + Toggle + + + + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + ) : null} + + + ))} + + + ))} + + ); } diff --git a/apps/web/features/shell/organization-settings-sidebar.tsx b/apps/web/features/shell/organization-settings-sidebar.tsx index 172d13917..7e16a3cdb 100644 --- a/apps/web/features/shell/organization-settings-sidebar.tsx +++ b/apps/web/features/shell/organization-settings-sidebar.tsx @@ -1,141 +1,144 @@ -"use client"; -import * as React from "react"; +'use client'; +import type { Project } from '@voidhash/db'; import { - GradientAvatar, - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - Skeleton, -} from "@voidhash/ui"; -import { Sidebar, SidebarContent, SidebarHeader } from "@voidhash/ui"; -import { useParams, usePathname } from "next/navigation"; -import Link from "next/link"; -import { NavMain } from "./nav-main"; -import type { Project } from "@voidhash/db"; + GradientAvatar, + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + Skeleton +} from '@voidhash/ui'; +import Link from 'next/link'; +import { useParams, usePathname } from 'next/navigation'; +import type * as React from 'react'; +import { NavMain } from './nav-main'; const SidebarProjects = ({ - organizationSlug, - projects, + organizationSlug, + projects }: { - organizationSlug: string; - projects: Project[]; + organizationSlug: string; + projects: Project[]; }) => { - return ( - - {projects.map((project) => ( - - - -
- - - {project.name} - -
- -
-
- ))} -
- ); + return ( + + {projects.map((project) => ( + + + +
+ + + {project.name} + +
+ +
+
+ ))} +
+ ); }; const SidebarProjectsSkeleton = () => { - return ( - - {Array.from({ length: 3 }).map((_, index) => ( - - -
- - -
-
-
- ))} -
- ); + return ( + + {Array.from({ length: 3 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton + + +
+ + +
+
+
+ ))} +
+ ); }; export function OrganizationSettingsSidebar({ - projects, - areProjectsLoading, - ...props + projects, + areProjectsLoading, + ...props }: React.ComponentProps & { - projects: Project[]; - areProjectsLoading: boolean; + projects: Project[]; + areProjectsLoading: boolean; }) { - const pathname = usePathname(); - const { organizationSlug } = useParams(); + const pathname = usePathname(); + const { organizationSlug } = useParams(); - const data = { - navMain: [ - { - title: "Team", - items: [ - { - title: "General", - url: `/${organizationSlug}/~/settings/general`, - isActive: () => - pathname.startsWith(`/${organizationSlug}/~/settings/general`), - }, - // TODO: Add members settings and billing - // { - // title: "Members", - // url: `/~/${organizationSlug}/settings/members`, - // isActive: () => - // routerState.location.pathname.startsWith( - // `/~/${organizationSlug}/settings/members` - // ), - // }, - ], - }, - ], - }; + const data = { + navMain: [ + { + title: 'Team', + items: [ + { + title: 'General', + url: `/${organizationSlug}/~/settings/general`, + isActive: () => + pathname.startsWith(`/${organizationSlug}/~/settings/general`) + } + // TODO: Add members settings and billing + // { + // title: "Members", + // url: `/~/${organizationSlug}/settings/members`, + // isActive: () => + // routerState.location.pathname.startsWith( + // `/~/${organizationSlug}/settings/members` + // ), + // }, + ] + } + ] + }; - if (!organizationSlug) { - return null; - } + if (!organizationSlug) { + return null; + } - return ( - - -
-
- Team Settings -
-
-
- - - - Projects - {areProjectsLoading ? ( - - ) : ( - - )} - - -
- ); + return ( + + +
+
+ Team Settings +
+
+
+ + + + Projects + {areProjectsLoading ? ( + + ) : ( + + )} + + +
+ ); } diff --git a/apps/web/features/shell/organization-sidebar.tsx b/apps/web/features/shell/organization-sidebar.tsx index a4b4bf187..fba23adc7 100644 --- a/apps/web/features/shell/organization-sidebar.tsx +++ b/apps/web/features/shell/organization-sidebar.tsx @@ -1,54 +1,54 @@ -"use client"; +'use client'; -import * as React from "react"; -import { Grid2X2, Settings } from "lucide-react"; -import { Sidebar, SidebarContent } from "@voidhash/ui"; -import { usePathname } from "next/navigation"; -import Link from "next/link"; -import { NavMain } from "./nav-main"; +import { Sidebar, SidebarContent } from '@voidhash/ui'; +import { Grid2X2, Settings } from 'lucide-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import type * as React from 'react'; +import { NavMain } from './nav-main'; export function OrganizationSidebar({ - organizationSlug, - collapsible = "icon", - ...props + organizationSlug, + collapsible = 'icon', + ...props }: React.ComponentProps & { - organizationSlug: string; + organizationSlug: string; }) { - const pathname = usePathname(); + const pathname = usePathname(); - const data = { - navMain: [ - { - title: "Team", - items: [ - { - title: "Projects", - url: `/${organizationSlug}`, - icon: Grid2X2, - isActive: () => pathname === `/${organizationSlug}`, - }, - { - title: "Settings", - url: `/${organizationSlug}/~/settings/general`, - icon: Settings, - isActive: () => - pathname.startsWith(`/${organizationSlug}/~/settings/general`), - }, - ], - }, - ], - }; + const data = { + navMain: [ + { + title: 'Team', + items: [ + { + title: 'Projects', + url: `/${organizationSlug}`, + icon: Grid2X2, + isActive: () => pathname === `/${organizationSlug}` + }, + { + title: 'Settings', + url: `/${organizationSlug}/~/settings/general`, + icon: Settings, + isActive: () => + pathname.startsWith(`/${organizationSlug}/~/settings/general`) + } + ] + } + ] + }; - return ( - - - - - - ); + return ( + + + + + + ); } diff --git a/apps/web/features/shell/page.tsx b/apps/web/features/shell/page.tsx index 229027d1f..59a2c0db4 100644 --- a/apps/web/features/shell/page.tsx +++ b/apps/web/features/shell/page.tsx @@ -1,69 +1,69 @@ import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, - cn, - Separator, - Skeleton, -} from "@voidhash/ui"; -import Link from "next/link"; -import { Fragment } from "react"; + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, + cn, + Separator, + Skeleton +} from '@voidhash/ui'; +import Link from 'next/link'; +import { Fragment } from 'react'; export function Page({ - children, - breadcrumbs, - className, -}: React.ComponentProps<"div"> & { - breadcrumbs?: { - title: string; - url?: string; - isLoading?: boolean; - }[]; + children, + breadcrumbs, + className +}: React.ComponentProps<'div'> & { + breadcrumbs?: { + title: string; + url?: string; + isLoading?: boolean; + }[]; }) { - return ( - <> - {breadcrumbs && breadcrumbs.length > 0 && ( -
-
- - - - {breadcrumbs?.map((breadcrumb, index) => ( - - {index < breadcrumbs.length - 1 && ( - - - - {breadcrumb.title} - - - - )} - {index === breadcrumbs.length - 1 && ( - - - {breadcrumb.isLoading ? ( - - ) : ( - breadcrumb.title - )} - - - )} - {index !== breadcrumbs.length - 1 && ( - - )} - - ))} - - -
-
- )} -
{children}
- - ); + return ( + <> + {breadcrumbs && breadcrumbs.length > 0 && ( +
+
+ + + + {breadcrumbs?.map((breadcrumb, index) => ( + + {index < breadcrumbs.length - 1 && ( + + + + {breadcrumb.title} + + + + )} + {index === breadcrumbs.length - 1 && ( + + + {breadcrumb.isLoading ? ( + + ) : ( + breadcrumb.title + )} + + + )} + {index !== breadcrumbs.length - 1 && ( + + )} + + ))} + + +
+
+ )} +
{children}
+ + ); } diff --git a/apps/web/features/shell/project-settings-sidebar.tsx b/apps/web/features/shell/project-settings-sidebar.tsx index dd4e43285..605e859c8 100644 --- a/apps/web/features/shell/project-settings-sidebar.tsx +++ b/apps/web/features/shell/project-settings-sidebar.tsx @@ -1,112 +1,117 @@ -"use client"; -import * as React from "react"; -import { GradientAvatar, Skeleton } from "@voidhash/ui"; -import { Sidebar, SidebarContent, SidebarHeader } from "@voidhash/ui"; -import { ChevronLeft } from "lucide-react"; -import { usePathname } from "next/navigation"; -import Link from "next/link"; -import { NavMain } from "./nav-main"; -import type { Organization } from "@voidhash/db"; +'use client'; +import type { Organization } from '@voidhash/db'; +import { + GradientAvatar, + Sidebar, + SidebarContent, + SidebarHeader, + Skeleton +} from '@voidhash/ui'; +import { ChevronLeft } from 'lucide-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import type * as React from 'react'; +import { NavMain } from './nav-main'; const ActiveOrganization = ({ - activeOrganization, + activeOrganization }: { - activeOrganization: Organization; + activeOrganization: Organization; }) => { - return ( -
- - + return ( +
+ + - - {activeOrganization.name} - -
- ); + + {activeOrganization.name} + +
+ ); }; const ActiveOrganizationSkeleton = () => { - return ( - <> - - - - ); + return ( + <> + + + + ); }; export function ProjectSettingsSidebar({ - activeOrganization, - isActiveOrganizationLoading, - organizationSlug, - projectSlug, - ...props + activeOrganization, + isActiveOrganizationLoading, + organizationSlug, + projectSlug, + ...props }: React.ComponentProps & { - activeOrganization: Organization | null; - isActiveOrganizationLoading: boolean; - organizationSlug: string; - projectSlug: string; + activeOrganization: Organization | null; + isActiveOrganizationLoading: boolean; + organizationSlug: string; + projectSlug: string; }) { - const pathname = usePathname(); + const pathname = usePathname(); - const data = { - navMain: [ - { - title: "Project", - items: [ - { - title: "General", - url: `/${organizationSlug}/${projectSlug}/settings/general`, - isActive: () => - pathname.startsWith( - `/${organizationSlug}/${projectSlug}/settings/general` - ), - }, - { - title: "Payment Providers", - url: `/${organizationSlug}/${projectSlug}/settings/payment-providers`, - isActive: () => - pathname.startsWith( - `/${organizationSlug}/${projectSlug}/settings/payment-providers` - ), - }, - ], - }, - ], - }; + const data = { + navMain: [ + { + title: 'Project', + items: [ + { + title: 'General', + url: `/${organizationSlug}/${projectSlug}/settings/general`, + isActive: () => + pathname.startsWith( + `/${organizationSlug}/${projectSlug}/settings/general` + ) + }, + { + title: 'Payment Providers', + url: `/${organizationSlug}/${projectSlug}/settings/payment-providers`, + isActive: () => + pathname.startsWith( + `/${organizationSlug}/${projectSlug}/settings/payment-providers` + ) + } + ] + } + ] + }; - return ( - - -
- - {isActiveOrganizationLoading || !activeOrganization ? ( - - ) : ( - - )} - + return ( + + +
+ + {isActiveOrganizationLoading || !activeOrganization ? ( + + ) : ( + + )} + -
- Project Settings -
-
-
- - - -
- ); +
+ Project Settings +
+
+
+ + + +
+ ); } diff --git a/apps/web/features/shell/project-sidebar.tsx b/apps/web/features/shell/project-sidebar.tsx index 905e41b5a..6c996e840 100644 --- a/apps/web/features/shell/project-sidebar.tsx +++ b/apps/web/features/shell/project-sidebar.tsx @@ -1,111 +1,102 @@ -"use client"; +'use client'; -import * as React from "react"; +import { Sidebar, SidebarContent, useSidebar } from '@voidhash/ui'; import { - GalleryHorizontalEnd, - GaugeIcon, - Package2, - Settings, - SquareTerminal, - Users, -} from "lucide-react"; -import { Sidebar, SidebarContent, useSidebar } from "@voidhash/ui"; -import { NavMain } from "./nav-main"; -import { usePathname } from "next/navigation"; -import Link from "next/link"; + GalleryHorizontalEnd, + GaugeIcon, + Package2, + Settings, + SquareTerminal, + Users +} from 'lucide-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import * as React from 'react'; +import { NavMain } from './nav-main'; export function ProjectSidebar({ - collapsible = "icon", - organizationSlug, - projectSlug, - ...props + collapsible = 'icon', + organizationSlug, + projectSlug, + ...props }: React.ComponentProps & { - organizationSlug: string; - projectSlug: string; + organizationSlug: string; + projectSlug: string; }) { - const pathname = usePathname(); + const pathname = usePathname(); - const isSettingsRoute = pathname.includes("/settings"); - const { setOpen } = useSidebar(); - React.useEffect(() => { - if (isSettingsRoute) { - setOpen(false); - } else if (!isSettingsRoute) { - setOpen(true); - } - }, [isSettingsRoute, setOpen]); + const isSettingsRoute = pathname.includes('/settings'); + const { setOpen } = useSidebar(); + React.useEffect(() => { + if (isSettingsRoute) { + setOpen(false); + } else if (!isSettingsRoute) { + setOpen(true); + } + }, [isSettingsRoute, setOpen]); - const data = { - navMain: [ - { - title: "Platform", - items: [ - { - title: "Overview", - url: `/${organizationSlug}/${projectSlug}`, - icon: GaugeIcon, - isActive: () => pathname == `/${organizationSlug}/${projectSlug}`, - }, - { - title: "Customers", - url: `/${organizationSlug}/${projectSlug}/customers`, - icon: Users, - isActive: () => - pathname.startsWith( - `/${organizationSlug}/${projectSlug}/customers` - ), - }, - { - title: "Products", - url: `/${organizationSlug}/${projectSlug}/products`, - icon: Package2, - isActive: () => - pathname.startsWith( - `/${organizationSlug}/${projectSlug}/products` - ), - }, - { - title: "Paywalls", - url: `/${organizationSlug}/${projectSlug}/paywalls`, - icon: GalleryHorizontalEnd, - isActive: () => - pathname.startsWith( - `/${organizationSlug}/${projectSlug}/paywalls` - ), - }, - { - title: "Developers", - url: `/${organizationSlug}/${projectSlug}/developers`, - icon: SquareTerminal, - isActive: () => - pathname.startsWith( - `/${organizationSlug}/${projectSlug}/developers` - ), - }, - { - title: "Settings", - url: `/${organizationSlug}/${projectSlug}/settings/general`, - icon: Settings, - isActive: () => - pathname.startsWith( - `/${organizationSlug}/${projectSlug}/settings/general` - ), - }, - ], - }, - ], - }; + const data = { + navMain: [ + { + title: 'Platform', + items: [ + { + title: 'Overview', + url: `/${organizationSlug}/${projectSlug}`, + icon: GaugeIcon, + isActive: () => pathname === `/${organizationSlug}/${projectSlug}` + }, + { + title: 'Customers', + url: `/${organizationSlug}/${projectSlug}/customers`, + icon: Users, + isActive: () => + pathname.startsWith( + `/${organizationSlug}/${projectSlug}/customers` + ) + }, + { + title: 'Products', + url: `/${organizationSlug}/${projectSlug}/products`, + icon: Package2, + isActive: () => + pathname.startsWith( + `/${organizationSlug}/${projectSlug}/products` + ) + }, + { + title: 'Developers', + url: `/${organizationSlug}/${projectSlug}/developers`, + icon: SquareTerminal, + isActive: () => + pathname.startsWith( + `/${organizationSlug}/${projectSlug}/developers` + ) + }, + { + title: 'Settings', + url: `/${organizationSlug}/${projectSlug}/settings/general`, + icon: Settings, + isActive: () => + pathname.startsWith( + `/${organizationSlug}/${projectSlug}/settings/general` + ) + } + ] + } + ] + }; - return ( - - - - - - ); + return ( + + + + + + ); } diff --git a/apps/web/features/trpc/query-client.tsx b/apps/web/features/trpc/query-client.tsx index 5780964e5..b3e57c635 100644 --- a/apps/web/features/trpc/query-client.tsx +++ b/apps/web/features/trpc/query-client.tsx @@ -1,33 +1,33 @@ import { - defaultShouldDehydrateQuery, - QueryClient, -} from "@tanstack/react-query"; -import SuperJSON from "superjson"; + defaultShouldDehydrateQuery, + QueryClient +} from '@tanstack/react-query'; +import SuperJSON from 'superjson'; export const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - // With SSR, we usually want to set some default staleTime - // above 0 to avoid refetching immediately on the client - staleTime: 30 * 1000, - }, - dehydrate: { - serializeData: SuperJSON.serialize, - shouldDehydrateQuery: (query) => - defaultShouldDehydrateQuery(query) || - query.state.status === "pending", - shouldRedactErrors: () => { - // We should not catch Next.js server errors - // as that's how Next.js detects dynamic pages - // so we cannot redact them. - // Next.js also automatically redacts errors for us - // with better digests. - return false; - }, - }, - hydrate: { - deserializeData: SuperJSON.deserialize, - }, - }, - }); + new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 30 * 1000 + }, + dehydrate: { + serializeData: SuperJSON.serialize, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === 'pending', + shouldRedactErrors: () => { + // We should not catch Next.js server errors + // as that's how Next.js detects dynamic pages + // so we cannot redact them. + // Next.js also automatically redacts errors for us + // with better digests. + return false; + } + }, + hydrate: { + deserializeData: SuperJSON.deserialize + } + } + }); diff --git a/apps/web/features/trpc/react.tsx b/apps/web/features/trpc/react.tsx index 1b610f2cc..e021fa872 100644 --- a/apps/web/features/trpc/react.tsx +++ b/apps/web/features/trpc/react.tsx @@ -1,72 +1,72 @@ -"use client"; +'use client'; -import type { QueryClient } from "@tanstack/react-query"; -import { useState } from "react"; -import { QueryClientProvider } from "@tanstack/react-query"; +import type { QueryClient } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { - createTRPCClient, - httpBatchStreamLink, - loggerLink, -} from "@trpc/client"; -import { createTRPCContext } from "@trpc/tanstack-react-query"; -import SuperJSON from "superjson"; + createTRPCClient, + httpBatchStreamLink, + loggerLink +} from '@trpc/client'; +import { createTRPCContext } from '@trpc/tanstack-react-query'; +import { APP_DOMAIN } from '@voidhash/lib'; +import { useState } from 'react'; +import SuperJSON from 'superjson'; +import { env } from '@/lib/env'; +import type { AppRouter } from '@/lib/trpc'; +import { createQueryClient } from './query-client'; -import type { AppRouter } from "@/lib/trpc"; - -import { createQueryClient } from "./query-client"; - -import { APP_DOMAIN } from "@voidhash/lib"; -import { env } from "@/lib/env"; - -let clientQueryClientSingleton: QueryClient | undefined = undefined; +let clientQueryClientSingleton: QueryClient | undefined; const getQueryClient = () => { - if (typeof window === "undefined") { - // Server: always make a new query client - return createQueryClient(); - } else { - // Browser: use singleton pattern to keep the same query client - return (clientQueryClientSingleton ??= createQueryClient()); - } + if (typeof window === 'undefined') { + // Server: always make a new query client + return createQueryClient(); + } + // Browser: use singleton pattern to keep the same query client + return clientQueryClientSingleton ?? createQueryClient(); }; export const { useTRPC, TRPCProvider } = createTRPCContext(); export function TRPCReactProvider(props: { children: React.ReactNode }) { - const queryClient = getQueryClient(); + const queryClient = getQueryClient(); - const [trpcClient] = useState(() => - createTRPCClient({ - links: [ - loggerLink({ - enabled: (op) => - env.NODE_ENV === "development" || - (op.direction === "down" && op.result instanceof Error), - }), - httpBatchStreamLink({ - transformer: SuperJSON, - url: getBaseUrl() + "/api/trpc", - headers() { - const headers = new Headers(); - headers.set("x-trpc-source", "nextjs-react"); - return headers; - }, - }), - ], - }) - ); + const [trpcClient] = useState(() => + createTRPCClient({ + links: [ + loggerLink({ + enabled: (op) => + env.NODE_ENV === 'development' || + (op.direction === 'down' && op.result instanceof Error) + }), + httpBatchStreamLink({ + transformer: SuperJSON, + url: `${getBaseUrl()}/api/trpc`, + headers() { + const headers = new Headers(); + headers.set('x-trpc-source', 'nextjs-react'); + return headers; + } + }) + ] + }) + ); - return ( - - - {props.children} - - - ); + return ( + + + {props.children} + + + ); } const getBaseUrl = () => { - if (typeof window !== "undefined") return window.location.origin; - if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`; - // eslint-disable-next-line no-restricted-properties - return APP_DOMAIN; + if (typeof window !== 'undefined') { + return window.location.origin; + } + if (env.VERCEL_URL) { + return `https://${env.VERCEL_URL}`; + } + + return APP_DOMAIN; }; diff --git a/apps/web/features/trpc/server.tsx b/apps/web/features/trpc/server.tsx index 78d1f6e11..059ce0e7d 100644 --- a/apps/web/features/trpc/server.tsx +++ b/apps/web/features/trpc/server.tsx @@ -1,57 +1,57 @@ -import type { TRPCQueryOptions } from "@trpc/tanstack-react-query"; -import { cache } from "react"; -import { headers } from "next/headers"; -import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; +import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; +import type { TRPCQueryOptions } from '@trpc/tanstack-react-query'; +import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'; +import { auth } from '@voidhash/auth'; +import { headers } from 'next/headers'; +import { cache } from 'react'; +import type { AppRouter } from '@/lib/trpc'; +import { appRouter, createTRPCContext } from '@/lib/trpc'; -import type { AppRouter } from "@/lib/trpc"; -import { appRouter, createTRPCContext } from "@/lib/trpc"; -import { auth } from "@voidhash/auth"; - -import { createQueryClient } from "./query-client"; +import { createQueryClient } from './query-client'; /** * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when * handling a tRPC call from a React Server Component. */ const createContext = cache(async () => { - const heads = new Headers(await headers()); - heads.set("x-trpc-source", "rsc"); + const heads = new Headers(await headers()); + heads.set('x-trpc-source', 'rsc'); - return createTRPCContext({ - session: await auth.api.getSession({ - headers: heads, - }), - headers: heads, - }); + return createTRPCContext({ + session: await auth.api.getSession({ + headers: heads + }), + headers: heads + }); }); const getQueryClient = cache(createQueryClient); export const trpc = createTRPCOptionsProxy({ - router: appRouter, - ctx: createContext, - queryClient: getQueryClient(), + router: appRouter, + ctx: createContext, + queryClient: getQueryClient() }); export function HydrateClient(props: { children: React.ReactNode }) { - const queryClient = getQueryClient(); - return ( - - {props.children} - - ); + const queryClient = getQueryClient(); + return ( + + {props.children} + + ); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// biome-ignore lint/suspicious/noExplicitAny: trpc export function prefetch>>( - queryOptions: T + queryOptions: T ) { - const queryClient = getQueryClient(); - if (queryOptions.queryKey[1]?.type === "infinite") { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - void queryClient.prefetchInfiniteQuery(queryOptions as any); - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - void queryClient.prefetchQuery(queryOptions as any); - } + const queryClient = getQueryClient(); + if (queryOptions.queryKey[1]?.type === 'infinite') { + // @ts-expect-error trpc + // biome-ignore lint/complexity/noVoid: trpc + void queryClient.prefetchInfiniteQuery(queryOptions); + } else { + // biome-ignore lint/complexity/noVoid: trpc + void queryClient.prefetchQuery(queryOptions); + } } diff --git a/apps/web/jobs/create-voidhash-customer-task.ts b/apps/web/jobs/create-voidhash-customer-task.ts index 3c0471df0..55b224ec3 100644 --- a/apps/web/jobs/create-voidhash-customer-task.ts +++ b/apps/web/jobs/create-voidhash-customer-task.ts @@ -1,29 +1,29 @@ -import { voidhash } from "@/lib/voidhash"; -import { task } from "@trigger.dev/sdk/v3"; +import { task } from '@trigger.dev/sdk/v3'; +import { voidhash } from '@/lib/voidhash'; // Combine different retry strategies type Payload = { - organizationId: string; - email: string; - name: string; + organizationId: string; + email: string; + name: string; }; export const createVoidhashCustomerTask = task({ - id: "create-voidhash-customer-task", - retry: { - maxAttempts: 3, - minTimeoutInMs: 1_000, - maxTimeoutInMs: 30_000, - factor: 2, - }, - run: async ({ organizationId, email, name }: Payload) => { - // If this throws (and isn't caught), the task will be retried - const customer = await voidhash.customers.create({ - // Customer in voidhash will be linked to the organization - appUserId: organizationId, - email, - name, - }); + id: 'create-voidhash-customer-task', + retry: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 30_000, + factor: 2 + }, + run: async ({ organizationId, email, name }: Payload) => { + // If this throws (and isn't caught), the task will be retried + const customer = await voidhash.customers.create({ + // Customer in voidhash will be linked to the organization + appUserId: organizationId, + email, + name + }); - return customer; - }, + return customer; + } }); diff --git a/apps/web/lib/api/api.ts b/apps/web/lib/api/api.ts index e4bafc61c..68f63fb54 100644 --- a/apps/web/lib/api/api.ts +++ b/apps/web/lib/api/api.ts @@ -1,37 +1,38 @@ -import { openAPISpecs } from "hono-openapi"; -import { newApp } from "./hono/app"; -import { API_DOMAIN } from "@voidhash/lib/constants"; -import { registerCustomersListCustomers } from "./v1/customers_listCustomers"; -import { registerCustomersGetCustomerByAppUserId } from "./v1/customers_getCustomerByAppUserId"; -import { registerPaywallsCreatePaywall } from "./v1/paywalls_createPaywall"; -import { registerPaywallsListPaywalls } from "./v1/paywalls_listPaywalls"; -import { registerPaywallsGetPaywallById } from "./v1/paywalls_getPaywallById"; -import { registerPaywallsDeletePaywall } from "./v1/paywalls_deletePaywall"; +import { API_DOMAIN } from '@voidhash/lib/constants'; +import { openAPISpecs } from 'hono-openapi'; +import { paymentProviderApis } from '@/lib/payment-providers/payment-providers-api'; +import { newApp } from './hono/app'; +import { registerCustomersCreateCustomer } from './v1/customers_createCustomer'; +import { registerCustomersGetCustomerByAppUserId } from './v1/customers_getCustomerByAppUserId'; +import { registerCustomersListCustomers } from './v1/customers_listCustomers'; +import { registerPaywallsCreatePaywall } from './v1/paywalls_createPaywall'; +import { registerPaywallsDeletePaywall } from './v1/paywalls_deletePaywall'; +import { registerPaywallsGetPaywallById } from './v1/paywalls_getPaywallById'; // import { registerPaywallsAttachProductToPaywall } from "./v1/paywalls_attachProductToPaywall"; -import { registerPaywallsGetPaywallProducts } from "./v1/paywalls_getPaywallProducts"; +import { registerPaywallsGetPaywallProducts } from './v1/paywalls_getPaywallProducts'; +import { registerPaywallsListPaywalls } from './v1/paywalls_listPaywalls'; +import { registerProductsAttachProviderProduct } from './v1/products_attachProviderProduct'; // import { registerPaywallsDeletePaywallProduct } from "./v1/paywalls_deletePaywallProduct"; -import { registerProductsCreateProduct } from "./v1/products_createProduct"; -import { registerProductsListProducts } from "./v1/products_listProducts"; -import { registerProductsGetProductById } from "./v1/products_getProductById"; -import { registerProductsUpdateProduct } from "./v1/products_updateProduct"; -import { registerProductsDeleteProduct } from "./v1/products_deleteProduct"; -import { registerProductsAttachProviderProduct } from "./v1/products_attachProviderProduct"; -import { registerProductsGetProviderProductsByProductId } from "./v1/products_getProviderProductsByProductId"; -import { registerProductsUpdateProviderProduct } from "./v1/products_updateProviderProduct"; -import { registerProductsDeleteProviderProduct } from "./v1/products_deleteProviderProduct"; -import { registerCustomersCreateCustomer } from "./v1/customers_createCustomer"; -import { paymentProviderApis } from "@/lib/payment-providers/payment-providers-api"; -import { registerSdkGetCustomer } from "./v1/sdk_getCustomer"; -import { registerSdkIdentify } from "./v1/sdk_identify"; -import { registerSdkGetPaywallByLocation } from "./v1/sdk_getPaywallByLocation"; -import { registerSdkCreateCheckout } from "./v1/sdk_createCheckout"; +import { registerProductsCreateProduct } from './v1/products_createProduct'; +import { registerProductsDeleteProduct } from './v1/products_deleteProduct'; +import { registerProductsDeleteProviderProduct } from './v1/products_deleteProviderProduct'; +import { registerProductsGetProductById } from './v1/products_getProductById'; +import { registerProductsGetProviderProductsByProductId } from './v1/products_getProviderProductsByProductId'; +import { registerProductsListProducts } from './v1/products_listProducts'; +import { registerProductsUpdateProduct } from './v1/products_updateProduct'; +import { registerProductsUpdateProviderProduct } from './v1/products_updateProviderProduct'; +import { registerSdkCreateCheckout } from './v1/sdk_createCheckout'; +import { registerSdkGetCustomer } from './v1/sdk_getCustomer'; +import { registerSdkGetPaywallByLocation } from './v1/sdk_getPaywallByLocation'; +import { registerSdkIdentify } from './v1/sdk_identify'; +import { registerSdkSyncCustomerAttributes } from './v1/sdk_syncCustomerAttributes'; -export const app = newApp(); +const app = newApp(); const url = - process.env.NODE_ENV === "development" - ? "http://localhost:3000" - : `${API_DOMAIN}`; + process.env.NODE_ENV === 'development' + ? 'http://localhost:3000' + : `${API_DOMAIN}`; // Customers registerCustomersCreateCustomer(app); @@ -63,35 +64,40 @@ registerSdkCreateCheckout(app); registerSdkGetCustomer(app); registerSdkIdentify(app); registerSdkGetPaywallByLocation(app); +registerSdkSyncCustomerAttributes(app); -paymentProviderApis.forEach((api) => api.registerEndpoints(app)); +for (const api of paymentProviderApis) { + api.registerEndpoints(app); +} app.get( - "/v1/openapi", - openAPISpecs(app, { - documentation: { - info: { - title: "Voidhash API", - version: "1.0.0", - description: "API", - }, - components: { - securitySchemes: { - secretKey: { - description: "Secret API key", - type: "apiKey", - name: "x-secret-key", - in: "header", - }, - publishableKey: { - description: "Publishable API key", - type: "apiKey", - name: "x-publishable-key", - in: "header", - }, - }, - }, - servers: [{ url, description: "Local Server" }], - }, - }) + '/v1/openapi', + openAPISpecs(app, { + documentation: { + info: { + title: 'Voidhash API', + version: '1.0.0', + description: 'API' + }, + components: { + securitySchemes: { + secretKey: { + description: 'Secret API key', + type: 'apiKey', + name: 'x-secret-key', + in: 'header' + }, + publishableKey: { + description: 'Publishable API key', + type: 'apiKey', + name: 'x-publishable-key', + in: 'header' + } + } + }, + servers: [{ url, description: 'Local Server' }] + } + }) ); + +export { app }; diff --git a/apps/web/lib/api/errors/http.ts b/apps/web/lib/api/errors/http.ts index c700dc993..9703a4788 100644 --- a/apps/web/lib/api/errors/http.ts +++ b/apps/web/lib/api/errors/http.ts @@ -1,128 +1,128 @@ // Credited to https://github.com/unkeyed/unkey -import { parseZodErrorMessage } from "@/lib/zod-error"; -import { VoidhashHTTPError } from "@voidhash/lib/constants"; -import type { Context } from "hono"; -import { HTTPException } from "hono/http-exception"; -import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status"; -import { z, type ZodError } from "zod"; -import { HonoEnv } from "../hono/env"; + +import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import type { ContentfulStatusCode, StatusCode } from 'hono/utils/http-status'; +import { type ZodError, z } from 'zod'; +import { parseZodErrorMessage } from '@/lib/zod-error'; +import type { HonoEnv } from '../hono/env'; // import { extendZodWithOpenApi } from "zod-openapi"; // extendZodWithOpenApi(z); export const ErrorCode = z.enum([ - "BAD_REQUEST", - "FORBIDDEN", - "INTERNAL_SERVER_ERROR", - "USAGE_EXCEEDED", - "DISABLED", - "NOT_FOUND", - "CONFLICT", - "RATE_LIMITED", - "UNAUTHORIZED", - "PRECONDITION_FAILED", - "INSUFFICIENT_PERMISSIONS", - "METHOD_NOT_ALLOWED", - "EXPIRED", - "DELETE_PROTECTED", + 'BAD_REQUEST', + 'FORBIDDEN', + 'INTERNAL_SERVER_ERROR', + 'USAGE_EXCEEDED', + 'DISABLED', + 'NOT_FOUND', + 'CONFLICT', + 'RATE_LIMITED', + 'UNAUTHORIZED', + 'PRECONDITION_FAILED', + 'INSUFFICIENT_PERMISSIONS', + 'METHOD_NOT_ALLOWED', + 'EXPIRED', + 'DELETE_PROTECTED' ]); -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// biome-ignore lint/suspicious/noExplicitAny: zod export function errorSchemaFactory(code: z.ZodEnum) { - return z.object({ - error: z.object({ - code: code.meta({ - description: "A machine readable error code.", - example: code._zod.def.entries[0], - }), - docs: z.string().meta({ - description: - "A link to our documentation with more details about this error code", - // TODO: Add example link - example: `https://unkey.dev/docs/api-reference/errors/code/${code._def.values.at(0)}`, - example: "", - }), - message: z.string().meta({ - description: "A human readable explanation of what went wrong", - }), - requestId: z.string().meta({ - description: "Please always include the requestId in your error report", - example: "req_1234", - }), - }), - }); + return z.object({ + error: z.object({ + code: code.meta({ + description: 'A machine readable error code.', + example: code._zod.def.entries[0] + }), + docs: z.string().meta({ + description: + 'A link to our documentation with more details about this error code', + // TODO: Add example link - example: `https://unkey.dev/docs/api-reference/errors/code/${code._def.values.at(0)}`, + example: '' + }), + message: z.string().meta({ + description: 'A human readable explanation of what went wrong' + }), + requestId: z.string().meta({ + description: 'Please always include the requestId in your error report', + example: 'req_1234' + }) + }) + }); } export const ErrorSchema = z.object({ - error: z.object({ - code: ErrorCode.meta({ - description: "A machine readable error code.", - example: "INTERNAL_SERVER_ERROR", - }), - docs: z.string().meta({ - description: - "A link to our documentation with more details about this error code", - // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/BAD_REQUEST`, - example: "", - }), - message: z.string().meta({ - description: "A human readable explanation of what went wrong", - }), - requestId: z.string().meta({ - description: "Please always include the requestId in your error report", - example: "req_1234", - }), - }), + error: z.object({ + code: ErrorCode.meta({ + description: 'A machine readable error code.', + example: 'INTERNAL_SERVER_ERROR' + }), + docs: z.string().meta({ + description: + 'A link to our documentation with more details about this error code', + // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/BAD_REQUEST`, + example: '' + }), + message: z.string().meta({ + description: 'A human readable explanation of what went wrong' + }), + requestId: z.string().meta({ + description: 'Please always include the requestId in your error report', + example: 'req_1234' + }) + }) }); export type ErrorResponse = z.infer; function codeToStatus(code: z.infer): ContentfulStatusCode { - switch (code) { - case "BAD_REQUEST": - return 400; - case "FORBIDDEN": - case "DISABLED": - case "UNAUTHORIZED": - case "INSUFFICIENT_PERMISSIONS": - case "USAGE_EXCEEDED": - case "EXPIRED": - return 403; - case "NOT_FOUND": - return 404; - case "METHOD_NOT_ALLOWED": - return 405; - case "CONFLICT": - return 409; - case "DELETE_PROTECTED": - case "PRECONDITION_FAILED": - return 412; - case "RATE_LIMITED": - return 429; - case "INTERNAL_SERVER_ERROR": - return 500; - } + switch (code) { + case 'BAD_REQUEST': + return 400; + case 'FORBIDDEN': + case 'DISABLED': + case 'UNAUTHORIZED': + case 'INSUFFICIENT_PERMISSIONS': + case 'USAGE_EXCEEDED': + case 'EXPIRED': + return 403; + case 'NOT_FOUND': + return 404; + case 'METHOD_NOT_ALLOWED': + return 405; + case 'CONFLICT': + return 409; + case 'DELETE_PROTECTED': + case 'PRECONDITION_FAILED': + return 412; + case 'RATE_LIMITED': + return 429; + default: + return 500; + } } function statusToCode(status: StatusCode): z.infer { - switch (status) { - case 400: - return "BAD_REQUEST"; - case 401: - return "UNAUTHORIZED"; - case 403: - return "FORBIDDEN"; + switch (status) { + case 400: + return 'BAD_REQUEST'; + case 401: + return 'UNAUTHORIZED'; + case 403: + return 'FORBIDDEN'; - case 404: - return "NOT_FOUND"; + case 404: + return 'NOT_FOUND'; - case 405: - return "METHOD_NOT_ALLOWED"; - case 500: - return "INTERNAL_SERVER_ERROR"; - default: - return "INTERNAL_SERVER_ERROR"; - } + case 405: + return 'METHOD_NOT_ALLOWED'; + case 500: + return 'INTERNAL_SERVER_ERROR'; + default: + return 'INTERNAL_SERVER_ERROR'; + } } // export class VoidhashApiError extends HTTPException { @@ -138,130 +138,130 @@ function statusToCode(status: StatusCode): z.infer { // } export function handleZodError( - result: - | { - success: true; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any; - } - | { - success: false; - error: ZodError; - }, - c: Context, + result: + | { + success: true; + // biome-ignore lint/suspicious/noExplicitAny: zod + data: any; + } + | { + success: false; + error: ZodError; + }, + c: Context ) { - if (!result.success) { - return c.json>( - { - error: { - code: "BAD_REQUEST", - // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/BAD_REQUEST`, - docs: "", - message: parseZodErrorMessage(result.error), - requestId: c.get("requestId"), - }, - }, - { status: 400 }, - ); - } + if (!result.success) { + return c.json>( + { + error: { + code: 'BAD_REQUEST', + // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/BAD_REQUEST`, + docs: '', + message: parseZodErrorMessage(result.error), + requestId: c.get('requestId') + } + }, + { status: 400 } + ); + } } export function handleError(err: Error, c: Context): Response { - const logger = c.get("logger"); + const logger = c.get('logger'); - /** - * We can handle this very well, as it is something we threw ourselves - */ - if (err instanceof VoidhashHTTPError) { - const status = codeToStatus(err.code); - if (status >= 500) { - logger.error("returning 5XX", { - message: err.message, - name: err.name, - code: err.code, - status: err.code, - }); - } - return c.json>( - { - error: { - code: err.code, - // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/${err.code}`, - docs: "", - message: err.message, - requestId: c.get("requestId"), - }, - }, - { status }, - ); - } + // /** + // * We can handle this very well, as it is something we threw ourselves + // */ + // if (err instanceof HonoErro) { + // const status = codeToStatus(err.code); + // if (status >= 500) { + // logger.error('returning 5XX', { + // message: err.message, + // name: err.name, + // code: err.code, + // status: err.code + // }); + // } + // return c.json>( + // { + // error: { + // code: err.code, + // // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/${err.code}`, + // docs: '', + // message: err.message, + // requestId: c.get('requestId') + // } + // }, + // { status } + // ); + // } - /** - * HTTPExceptions from hono at least give us some idea of what to do as they provide a status and - * message - */ - if (err instanceof HTTPException) { - if (err.status >= 500) { - logger.error("HTTPException", { - message: err.message, - status: err.status, - requestId: c.get("requestId"), - }); - } - const code = statusToCode(err.status); - return c.json>( - { - error: { - code, - // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/${code}`, - docs: "", - message: err.message, - requestId: c.get("requestId"), - }, - }, - { status: err.status }, - ); - } + /** + * HTTPExceptions from hono at least give us some idea of what to do as they provide a status and + * message + */ + if (err instanceof HTTPException) { + if (err.status >= 500) { + logger.error('HTTPException', { + message: err.message, + status: err.status, + requestId: c.get('requestId') + }); + } + const code = statusToCode(err.status); + return c.json>( + { + error: { + code, + // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/${code}`, + docs: '', + message: err.message, + requestId: c.get('requestId') + } + }, + { status: err.status } + ); + } - /** - * We're lost here, all we can do is return a 500 and log it to investigate - */ - logger.error("unhandled exception", { - name: err.name, - message: err.message, - cause: err.cause, - stack: err.stack, - requestId: c.get("requestId"), - }); - return c.json>( - { - error: { - code: "INTERNAL_SERVER_ERROR", - // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/INTERNAL_SERVER_ERROR`, - docs: "", - message: err.message ?? "something unexpected happened", - requestId: c.get("requestId"), - }, - }, - { status: 500 }, - ); + /** + * We're lost here, all we can do is return a 500 and log it to investigate + */ + logger.error('unhandled exception', { + name: err.name, + message: err.message, + cause: err.cause, + stack: err.stack, + requestId: c.get('requestId') + }); + return c.json>( + { + error: { + code: 'INTERNAL_SERVER_ERROR', + // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/INTERNAL_SERVER_ERROR`, + docs: '', + message: err.message ?? 'something unexpected happened', + requestId: c.get('requestId') + } + }, + { status: 500 } + ); } export function errorResponse( - c: Context, - code: z.infer, - message: string, + c: Context, + code: z.infer, + message: string ) { - return c.json>( - { - error: { - code: code, - // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/${code}`, - docs: "", - message, - requestId: c.get("requestId"), - }, - }, - { status: codeToStatus(code) }, - ); + return c.json>( + { + error: { + code, + // TODO: Add example link docs: `https://unkey.dev/docs/api-reference/errors/code/${code}`, + docs: '', + message, + requestId: c.get('requestId') + } + }, + { status: codeToStatus(code) } + ); } diff --git a/apps/web/lib/api/errors/openapi_responses.ts b/apps/web/lib/api/errors/openapi_responses.ts index debb47e53..f38094626 100644 --- a/apps/web/lib/api/errors/openapi_responses.ts +++ b/apps/web/lib/api/errors/openapi_responses.ts @@ -1,109 +1,110 @@ // Credited to https://github.com/unkeyed/unkey -import { z } from "zod"; -import { errorSchemaFactory } from "./http"; -import { resolver } from "hono-openapi/zod"; + +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; +import { errorSchemaFactory } from './http'; export const openApiErrorResponses = { - 400: { - description: - "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", - content: { - "application/json": { - schema: resolver( - errorSchemaFactory(z.enum(["BAD_REQUEST"])).meta({ - ref: "ErrBadRequest", - }) - ), - }, - }, - }, - 401: { - description: `Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response.`, - content: { - "application/json": { - schema: resolver( - errorSchemaFactory(z.enum(["UNAUTHORIZED"])).meta({ - ref: "ErrUnauthorized", - }) - ), - }, - }, - }, - 403: { - description: - "The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server.", - content: { - "application/json": { - schema: resolver( - errorSchemaFactory(z.enum(["FORBIDDEN"])).meta({ - ref: "ErrForbidden", - }) - ), - }, - }, - }, - 404: { - description: - "The server cannot find the requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of 403 Forbidden to hide the existence of a resource from an unauthorized client. This response code is probably the most well known due to its frequent occurrence on the web.", - content: { - "application/json": { - schema: resolver( - errorSchemaFactory(z.enum(["NOT_FOUND"])).meta({ - ref: "ErrNotFound", - }) - ), - }, - }, - }, - 409: { - description: - "This response is sent when a request conflicts with the current state of the server.", - content: { - "application/json": { - schema: resolver( - errorSchemaFactory(z.enum(["CONFLICT"])).meta({ - ref: "ErrConflict", - }) - ), - }, - }, - }, - 412: { - description: - "The requested operation cannot be completed because certain conditions were not met. This typically occurs when a required resource state or version check fails.", - content: { - "application/json": { - schema: resolver( - errorSchemaFactory(z.enum(["PRECONDITION_FAILED"])).meta({ - ref: "ErrPreconditionFailed", - }) - ), - }, - }, - }, - 429: { - description: `The user has sent too many requests in a given amount of time ("rate limiting")`, - content: { - "application/json": { - schema: resolver( - errorSchemaFactory(z.enum(["TOO_MANY_REQUESTS"])).meta({ - ref: "ErrTooManyRequests", - }) - ), - }, - }, - }, - 500: { - description: - "The server has encountered a situation it does not know how to handle.", - content: { - "application/json": { - schema: resolver( - errorSchemaFactory(z.enum(["INTERNAL_SERVER_ERROR"])).meta({ - ref: "ErrInternalServerError", - }) - ), - }, - }, - }, + 400: { + description: + 'The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).', + content: { + 'application/json': { + schema: resolver( + errorSchemaFactory(z.enum(['BAD_REQUEST'])).meta({ + ref: 'ErrBadRequest' + }) + ) + } + } + }, + 401: { + description: `Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response.`, + content: { + 'application/json': { + schema: resolver( + errorSchemaFactory(z.enum(['UNAUTHORIZED'])).meta({ + ref: 'ErrUnauthorized' + }) + ) + } + } + }, + 403: { + description: + "The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server.", + content: { + 'application/json': { + schema: resolver( + errorSchemaFactory(z.enum(['FORBIDDEN'])).meta({ + ref: 'ErrForbidden' + }) + ) + } + } + }, + 404: { + description: + 'The server cannot find the requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of 403 Forbidden to hide the existence of a resource from an unauthorized client. This response code is probably the most well known due to its frequent occurrence on the web.', + content: { + 'application/json': { + schema: resolver( + errorSchemaFactory(z.enum(['NOT_FOUND'])).meta({ + ref: 'ErrNotFound' + }) + ) + } + } + }, + 409: { + description: + 'This response is sent when a request conflicts with the current state of the server.', + content: { + 'application/json': { + schema: resolver( + errorSchemaFactory(z.enum(['CONFLICT'])).meta({ + ref: 'ErrConflict' + }) + ) + } + } + }, + 412: { + description: + 'The requested operation cannot be completed because certain conditions were not met. This typically occurs when a required resource state or version check fails.', + content: { + 'application/json': { + schema: resolver( + errorSchemaFactory(z.enum(['PRECONDITION_FAILED'])).meta({ + ref: 'ErrPreconditionFailed' + }) + ) + } + } + }, + 429: { + description: `The user has sent too many requests in a given amount of time ("rate limiting")`, + content: { + 'application/json': { + schema: resolver( + errorSchemaFactory(z.enum(['TOO_MANY_REQUESTS'])).meta({ + ref: 'ErrTooManyRequests' + }) + ) + } + } + }, + 500: { + description: + 'The server has encountered a situation it does not know how to handle.', + content: { + 'application/json': { + schema: resolver( + errorSchemaFactory(z.enum(['INTERNAL_SERVER_ERROR'])).meta({ + ref: 'ErrInternalServerError' + }) + ) + } + } + } }; diff --git a/apps/web/lib/api/hono/app.ts b/apps/web/lib/api/hono/app.ts index 7d6f0e180..0d22d58d5 100644 --- a/apps/web/lib/api/hono/app.ts +++ b/apps/web/lib/api/hono/app.ts @@ -1,52 +1,52 @@ -import { Hono } from "hono"; -import { Scalar } from "@scalar/hono-api-reference"; -import { prettyJSON } from "hono/pretty-json"; -import { HonoEnv } from "./env"; -import { handleError } from "../errors/http"; -import type { Context as GenericContext } from "hono"; +import { Scalar } from '@scalar/hono-api-reference'; +import type { Context as GenericContext } from 'hono'; +import { Hono } from 'hono'; +import { prettyJSON } from 'hono/pretty-json'; +import { handleError } from '../errors/http'; +import type { HonoEnv } from './env'; // import { cors } from "hono/cors"; -import { init } from "./middleware/init"; +import { init } from './middleware/init'; export function newApp() { - let app = new Hono(); - - const basePath = process.env.NODE_ENV === "development" ? "/api" : ""; - - app = app.basePath(basePath); - app.use("*", (c, next) => { - // TODO: Fix this for vercel - c.set( - "location", - c.req.header("True-Client-IP") ?? - c.req.header("CF-Connecting-IP") ?? - // @ts-expect-error - the cf object will be there on cloudflare - c.req.raw?.cf?.colo ?? - "" - ); - c.set("userAgent", c.req.header("User-Agent")); - - return next(); - }); - - app.use(init()); - // app.use(cors()); - app.use(prettyJSON()); - - app.get( - "/docs", - Scalar({ - sources: [ - { - url: `${basePath}/v1/openapi`, - title: "v1", - }, - ], - theme: "default", - }) - ); - - app.onError(handleError); - return app; + let app = new Hono(); + + const basePath = process.env.NODE_ENV === 'development' ? '/api' : ''; + + app = app.basePath(basePath); + app.use('*', (c, next) => { + // TODO: Fix this for vercel + c.set( + 'location', + c.req.header('True-Client-IP') ?? + c.req.header('CF-Connecting-IP') ?? + // @ts-expect-error - the cf object will be there on cloudflare + c.req.raw?.cf?.colo ?? + '' + ); + c.set('userAgent', c.req.header('User-Agent')); + + return next(); + }); + + app.use(init()); + // app.use(cors()); + app.use(prettyJSON()); + + app.get( + '/docs', + Scalar({ + sources: [ + { + url: `${basePath}/v1/openapi`, + title: 'v1' + } + ], + theme: 'default' + }) + ); + + app.onError(handleError); + return app; } export type App = ReturnType; diff --git a/apps/web/lib/api/hono/env.ts b/apps/web/lib/api/hono/env.ts index 24e5dcf91..55318fd2c 100644 --- a/apps/web/lib/api/hono/env.ts +++ b/apps/web/lib/api/hono/env.ts @@ -1,21 +1,21 @@ -import { Logger } from "@/lib/logger/types"; +import type { Logger } from '@/lib/logger/types'; export type HonoEnv = { - Variables: { - isolateId: string; - isolateCreatedAt: number; - requestId: string; - requestStartedAt: number; - workspaceId?: string; - metricsContext: { - keyId?: string; - [key: string]: unknown; - }; - logger: Logger; - /** - * IP address or region information - */ - location: string; - userAgent?: string; - }; + Variables: { + isolateId: string; + isolateCreatedAt: number; + requestId: string; + requestStartedAt: number; + workspaceId?: string; + metricsContext: { + keyId?: string; + [key: string]: unknown; + }; + logger: Logger; + /** + * IP address or region information + */ + location: string; + userAgent?: string; + }; }; diff --git a/apps/web/lib/api/hono/middleware/init.ts b/apps/web/lib/api/hono/middleware/init.ts index db40e6d7f..e58e5ce22 100644 --- a/apps/web/lib/api/hono/middleware/init.ts +++ b/apps/web/lib/api/hono/middleware/init.ts @@ -1,8 +1,8 @@ -import { MiddlewareHandler } from "hono"; -import { HonoEnv } from "../env"; -import { generateId } from "@/lib/id/generate"; -import { ConsoleLogger } from "@/lib/logger/console"; -import { env } from "@/lib/env"; +import type { MiddlewareHandler } from 'hono'; +import { env } from '@/lib/env'; +import { generateId } from '@/lib/id/generate'; +import { ConsoleLogger } from '@/lib/logger/console'; +import type { HonoEnv } from '../env'; /** * workerId and coldStartAt are used to track the lifetime of the worker @@ -10,39 +10,39 @@ import { env } from "@/lib/env"; * * subsequent requests will use the same workerId and coldStartAt */ -let isolateId: string | undefined = undefined; -let isolateCreatedAt: number | undefined = undefined; +let isolateId: string | undefined; +let isolateCreatedAt: number | undefined; /** * Initialize all services. * * Call this once before any hono handlers run. */ export function init(): MiddlewareHandler { - return async (c, next) => { - if (!isolateId) { - isolateId = crypto.randomUUID(); - } - if (!isolateCreatedAt) { - isolateCreatedAt = Date.now(); - } - c.set("isolateId", isolateId); - c.set("isolateCreatedAt", isolateCreatedAt); - const requestId = generateId("request"); - c.set("requestId", requestId); + return async (c, next) => { + if (!isolateId) { + isolateId = crypto.randomUUID(); + } + if (!isolateCreatedAt) { + isolateCreatedAt = Date.now(); + } + c.set('isolateId', isolateId); + c.set('isolateCreatedAt', isolateCreatedAt); + const requestId = generateId('request'); + c.set('requestId', requestId); - c.set("requestStartedAt", Date.now()); + c.set('requestStartedAt', Date.now()); - c.res.headers.set("Voidhash-Request-Id", requestId); + c.res.headers.set('Voidhash-Request-Id', requestId); - const logger = new ConsoleLogger({ - requestId, - application: "api", - environment: env.VERCEL_ENV ?? "unknown", - defaultFields: { environment: env.VERCEL_ENV ?? "unknown" }, - }); + const logger = new ConsoleLogger({ + requestId, + application: 'api', + environment: env.VERCEL_ENV ?? 'unknown', + defaultFields: { environment: env.VERCEL_ENV ?? 'unknown' } + }); - c.set("logger", logger); + c.set('logger', logger); - await next(); - }; + await next(); + }; } diff --git a/apps/web/lib/api/schema.ts b/apps/web/lib/api/schema.ts index 6fbdb3063..41b77415c 100644 --- a/apps/web/lib/api/schema.ts +++ b/apps/web/lib/api/schema.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; +import { z } from 'zod'; export const errorResponseSchema = z.object({ - error: z.string(), + error: z.string() }); diff --git a/apps/web/lib/api/utils/hono-cookies-adapter.ts b/apps/web/lib/api/utils/hono-cookies-adapter.ts index b966da31d..2006c387b 100644 --- a/apps/web/lib/api/utils/hono-cookies-adapter.ts +++ b/apps/web/lib/api/utils/hono-cookies-adapter.ts @@ -1,19 +1,25 @@ -import { CookiesAdapter } from "@/lib/cookies-adapter"; -import { getCookie, setCookie, deleteCookie } from "hono/cookie"; -import { Context } from "hono"; +import type { Context } from 'hono'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; +import type { CookiesAdapter } from '@/lib/cookies-adapter'; export class HonoCookiesAdapter implements CookiesAdapter { - constructor(private readonly honoContext: Context) {} + private readonly honoContext: Context; + constructor(honoContext: Context) { + this.honoContext = honoContext; + } - async get(name: string): Promise { - return getCookie(this.honoContext, name) ?? null; - } + // biome-ignore lint/suspicious/useAwait: need to match CookiesAdapter interface + async get(name: string): Promise { + return getCookie(this.honoContext, name) ?? null; + } - async set(name: string, value: string): Promise { - setCookie(this.honoContext, name, value); - } + // biome-ignore lint/suspicious/useAwait: need to match CookiesAdapter interface + async set(name: string, value: string): Promise { + setCookie(this.honoContext, name, value); + } - async delete(name: string): Promise { - deleteCookie(this.honoContext, name); - } + // biome-ignore lint/suspicious/useAwait: need to match CookiesAdapter interface + async delete(name: string): Promise { + deleteCookie(this.honoContext, name); + } } diff --git a/apps/web/lib/api/v1/customers_createCustomer.test.ts b/apps/web/lib/api/v1/customers_createCustomer.test.ts index 425bffd3a..47636136a 100644 --- a/apps/web/lib/api/v1/customers_createCustomer.test.ts +++ b/apps/web/lib/api/v1/customers_createCustomer.test.ts @@ -1,91 +1,91 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { customers } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { - CustomersCreateCustomerRequestBody, - CustomersCreateCustomerResponse, -} from "./customers_createCustomer"; +import { customers } from '@voidhash/db'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { + CustomersCreateCustomerRequestBody, + CustomersCreateCustomerResponse +} from './customers_createCustomer'; -describe.sequential("/v1/customers", async () => { - test("POST /v1/customers - create customer", async (t) => { - const h = await IntegrationHarness.init(t); - const testAppUserId = generateId("test"); - const customerInput: CustomersCreateCustomerRequestBody = { - email: "test@test.com", - name: "Test Customer", - appUserId: testAppUserId, - }; +describe.sequential('/v1/customers', () => { + test('POST /v1/customers - create customer', async (t) => { + const h = await IntegrationHarness.init(t); + const testAppUserId = generateId('test'); + const customerInput: CustomersCreateCustomerRequestBody = { + email: 'test@test.com', + name: 'Test Customer', + appUserId: testAppUserId + }; - const res = await h.post({ - url: "/v1/customers", - headers: { - "Content-Type": "application/json", - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - body: customerInput, - }); + const res = await h.post({ + url: '/v1/customers', + headers: { + 'Content-Type': 'application/json', + 'x-secret-key': h.resources.secretKey.unhashedKey + }, + body: customerInput + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as CustomersCreateCustomerResponse; + const responseBody = res.body as CustomersCreateCustomerResponse; - expect(responseBody.customerId).toBeDefined(); - expect(responseBody.email).toBe(customerInput.email); - expect(responseBody.name).toBe(customerInput.name); - expect(responseBody.appUserId).toBe(customerInput.appUserId); - // expect(responseBody.origin).toBe("api"); + expect(responseBody.customerId).toBeDefined(); + expect(responseBody.email).toBe(customerInput.email); + expect(responseBody.name).toBe(customerInput.name); + expect(responseBody.appUserId).toBe(customerInput.appUserId); + // expect(responseBody.origin).toBe("api"); - // Clean up the created customer - t.onTestFinished(async () => { - if (responseBody?.customerId) { - await h.db.primary - .delete(customers) - .where(eq(customers.id, responseBody.customerId)); - } - }); - }); + // Clean up the created customer + t.onTestFinished(async () => { + if (responseBody?.customerId) { + await h.db.primary + .delete(customers) + .where(eq(customers.id, responseBody.customerId)); + } + }); + }); - test("POST /v1/customers - create customer minimal", async (t) => { - const h = await IntegrationHarness.init(t); - const testAppUserId = generateId("test"); - const customerInput: CustomersCreateCustomerRequestBody = { - appUserId: testAppUserId, - }; + test('POST /v1/customers - create customer minimal', async (t) => { + const h = await IntegrationHarness.init(t); + const testAppUserId = generateId('test'); + const customerInput: CustomersCreateCustomerRequestBody = { + appUserId: testAppUserId + }; - const res = await h.post({ - url: "/v1/customers", - headers: { - "Content-Type": "application/json", - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - body: customerInput, - }); + const res = await h.post({ + url: '/v1/customers', + headers: { + 'Content-Type': 'application/json', + 'x-secret-key': h.resources.secretKey.unhashedKey + }, + body: customerInput + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as CustomersCreateCustomerResponse; + const responseBody = res.body as CustomersCreateCustomerResponse; - expect(responseBody.customerId).toBeDefined(); - expect(responseBody.email).toBeNull(); - expect(responseBody.name).toBeNull(); - expect(responseBody.appUserId).toBe(customerInput.appUserId); - // expect(responseBody.origin).toBe("api"); + expect(responseBody.customerId).toBeDefined(); + expect(responseBody.email).toBeNull(); + expect(responseBody.name).toBeNull(); + expect(responseBody.appUserId).toBe(customerInput.appUserId); + // expect(responseBody.origin).toBe("api"); - // Clean up the created customer - t.onTestFinished(async () => { - if (responseBody?.customerId) { - await h.db.primary - .delete(customers) - .where(eq(customers.id, responseBody.customerId)); - } - }); - }); + // Clean up the created customer + t.onTestFinished(async () => { + if (responseBody?.customerId) { + await h.db.primary + .delete(customers) + .where(eq(customers.id, responseBody.customerId)); + } + }); + }); }); diff --git a/apps/web/lib/api/v1/customers_createCustomer.ts b/apps/web/lib/api/v1/customers_createCustomer.ts index edb629915..e4752c18f 100644 --- a/apps/web/lib/api/v1/customers_createCustomer.ts +++ b/apps/web/lib/api/v1/customers_createCustomer.ts @@ -1,86 +1,100 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { createCustomerBodySchema, customerResponseSchema } from "./schema"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; -import { z } from "zod"; -import { createEffectHandler } from "@/lib/effect/runtimes/hono"; -import { CustomerService } from "@/lib/services/customer.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; -import { CustomerOrigin } from "@voidhash/db"; +import { zValidator } from '@hono/zod-validator'; +import { CustomerOrigin } from '@voidhash/db'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { CustomerService } from '@/lib/services/customer.service'; +import { + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { createCustomerBodySchema, customerResponseSchema } from './schema'; const route = describeRoute({ - description: "Create a new customer", - operationId: "createCustomer", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(customerResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Customers"], + description: 'Create a new customer', + operationId: 'createCustomer', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(customerResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['Customers'] }); export type Route = typeof route; export const registerCustomersCreateCustomer = (app: App) => - app.post( - "/v1/customers", - route, - zValidator("json", createCustomerBodySchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const customerService = yield* CustomerService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); + app.post( + '/v1/customers', + route, + zValidator('json', createCustomerBodySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const customerService = yield* CustomerService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); - const projectId = yield* authService.getAuthorizedProjectId(); - const customer = yield* Environment.provide(environment)( - customerService.createCustomer({ - email: c.req.valid("json").email, - name: c.req.valid("json").name, - appUserId: c.req.valid("json").appUserId, - origin: CustomerOrigin.API, - projectId, - }) - ); + const projectId = yield* authService.getAuthorizedProjectId(); + const customer = yield* Environment.provide(environment)( + customerService + .createCustomer({ + appUserId: c.req.valid('json').appUserId, + origin: CustomerOrigin.API, + projectId, + environment + }) + .pipe( + Effect.catchTags({ + InvalidAnonymousIdError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); - return c.json>({ - customerId: customer.id, - name: customer.name ?? null, - email: customer.email ?? null, - appUserId: customer.appUserId ?? null, - }); - }) - ); - }) - ) - ); + return c.json>({ + customerId: customer.id, + name: customer.name ?? null, + email: customer.email ?? null, + appUserId: customer.appUserId ?? null + }); + }) + ); + }) + ) + ); export type CustomersCreateCustomerRequestBody = z.infer< - typeof createCustomerBodySchema + typeof createCustomerBodySchema >; export type CustomersCreateCustomerResponse = z.infer< - typeof customerResponseSchema + typeof customerResponseSchema >; diff --git a/apps/web/lib/api/v1/customers_getCustomerByAppUserId.test.ts b/apps/web/lib/api/v1/customers_getCustomerByAppUserId.test.ts index 462edf0e1..bf798e19a 100644 --- a/apps/web/lib/api/v1/customers_getCustomerByAppUserId.test.ts +++ b/apps/web/lib/api/v1/customers_getCustomerByAppUserId.test.ts @@ -1,82 +1,82 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { InsertCustomer, customers, CustomerOrigin } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { customerResponseSchema } from "./schema"; -import { z } from "zod"; -import { Environment } from "@voidhash/lib/constants"; +import { CustomerOrigin, customers, type InsertCustomer } from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { customerResponseSchema } from './schema'; -describe.sequential("/v1/customers/**", async () => { - test("GET /v1/customers/by-app-user-id/:appUserId - success", async (t) => { - const h = await IntegrationHarness.init(t); - const testAppUserId = `test-app-user-${generateId("test")}`; +describe.sequential('/v1/customers/**', () => { + test('GET /v1/customers/by-app-user-id/:appUserId - success', async (t) => { + const h = await IntegrationHarness.init(t); + const testAppUserId = `test-app-user-${generateId('test')}`; - // Directly insert a customer for testing - const customerInput: Omit = { - id: generateId("test"), - email: "getbyappid@test.com", - name: "Get By App User ID Test", - appUserId: testAppUserId, - origin: CustomerOrigin.API, - environment: Environment.Production, - }; + // Directly insert a customer for testing + const customerInput: Omit = { + id: generateId('test'), + email: 'getbyappid@test.com', + name: 'Get By App User ID Test', + appUserId: testAppUserId, + origin: CustomerOrigin.API, + environment: Environment.Production + }; - await h.db.primary.insert(customers).values({ - ...customerInput, - projectId: h.resources.project.id, - }); + await h.db.primary.insert(customers).values({ + ...customerInput, + projectId: h.resources.project.id + }); - const res = await h.get({ - url: `/v1/customers/by-app-user-id/${testAppUserId}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: `/v1/customers/by-app-user-id/${testAppUserId}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer; + const responseBody = res.body as z.infer; - expect(responseBody.customerId).toBe(customerInput.id); - expect(responseBody.email).toBe(customerInput.email); - expect(responseBody.name).toBe(customerInput.name); - expect(responseBody.appUserId).toBe(customerInput.appUserId); - // expect(responseBody.origin).toBe(customerInput.origin); + expect(responseBody.customerId).toBe(customerInput.id); + expect(responseBody.email).toBe(customerInput.email); + expect(responseBody.name).toBe(customerInput.name); + expect(responseBody.appUserId).toBe(customerInput.appUserId); + // expect(responseBody.origin).toBe(customerInput.origin); - // Clean up the created customer - t.onTestFinished(async () => { - await h.db.primary - .delete(customers) - .where(eq(customers.id, customerInput.id)); - }); - }); + // Clean up the created customer + t.onTestFinished(async () => { + await h.db.primary + .delete(customers) + .where(eq(customers.id, customerInput.id)); + }); + }); - test("GET /v1/customers/by-app-user-id/:appUserId - not found", async (t) => { - const h = await IntegrationHarness.init(t); - const nonExistentAppUserId = `non-existent-${generateId("test")}`; + test('GET /v1/customers/by-app-user-id/:appUserId - not found', async (t) => { + const h = await IntegrationHarness.init(t); + const nonExistentAppUserId = `non-existent-${generateId('test')}`; - const res = await h.get({ - url: `/v1/customers/by-app-user-id/${nonExistentAppUserId}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: `/v1/customers/by-app-user-id/${nonExistentAppUserId}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 404, received: ${JSON.stringify(res, null, 2)}` - ).toBe(404); - expect(res.body).toEqual({ - error: { - code: "NOT_FOUND", - docs: expect.any(String), - message: "Customer not found", - requestId: expect.any(String), - }, - }); - }); + expect( + res.status, + `expected 404, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + expect(res.body).toEqual({ + error: { + code: 'NOT_FOUND', + docs: expect.any(String), + message: 'Customer not found', + requestId: expect.any(String) + } + }); + }); }); diff --git a/apps/web/lib/api/v1/customers_getCustomerByAppUserId.ts b/apps/web/lib/api/v1/customers_getCustomerByAppUserId.ts index 70a6fc927..8e2527474 100644 --- a/apps/web/lib/api/v1/customers_getCustomerByAppUserId.ts +++ b/apps/web/lib/api/v1/customers_getCustomerByAppUserId.ts @@ -1,71 +1,71 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { customerResponseSchema } from "./schema"; -import { App } from "../hono/app"; -import { z } from "zod"; -import { createEffectHandler } from "@/lib/effect/runtimes/hono"; -import { CustomerService } from "@/lib/services/customer.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; +import { NotFoundError } from '@/lib/effect/errors'; +import { createEffectHandler } from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { CustomerService } from '@/lib/services/customer.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; -import { NotFoundError } from "@/lib/effect/errors"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { customerResponseSchema } from './schema'; const route = describeRoute({ - description: "Get a customer by app user ID", - operationId: "getCustomerByAppUserId", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(customerResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Customers"], + description: 'Get a customer by app user ID', + operationId: 'getCustomerByAppUserId', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(customerResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['Customers'] }); export type Route = typeof route; export const registerCustomersGetCustomerByAppUserId = (app: App) => - app.get("/v1/customers/by-app-user-id/:appUserId", route, async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const customerService = yield* CustomerService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); + app.get('/v1/customers/by-app-user-id/:appUserId', route, async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const customerService = yield* CustomerService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); - const customer = yield* Environment.provide(environment)( - customerService.getCustomerByAppUserId(c.req.param("appUserId")), - ).pipe( - Effect.catchTags({ - CustomerNotFoundError: (error) => - Effect.fail(new NotFoundError({ message: error.message })), - }), - ); + const customer = yield* Environment.provide(environment)( + customerService.getCustomerByAppUserId(c.req.param('appUserId')) + ).pipe( + Effect.catchTags({ + CustomerNotFoundError: (error) => + Effect.fail(new NotFoundError({ message: error.message })) + }) + ); - return c.json>({ - customerId: customer.id, - name: customer.name ?? null, - email: customer.email ?? null, - appUserId: customer.appUserId ?? null, - }); - }), - ); - }), - ), - ); + return c.json>({ + customerId: customer.id, + name: customer.name ?? null, + email: customer.email ?? null, + appUserId: customer.appUserId ?? null + }); + }) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/customers_listCustomers.test.ts b/apps/web/lib/api/v1/customers_listCustomers.test.ts index f52f0cddc..a31ceb456 100644 --- a/apps/web/lib/api/v1/customers_listCustomers.test.ts +++ b/apps/web/lib/api/v1/customers_listCustomers.test.ts @@ -1,71 +1,71 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { InsertCustomer, customers, CustomerOrigin } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { Environment } from "@voidhash/lib/constants"; +import { CustomerOrigin, customers, type InsertCustomer } from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; -const customerInput: Omit = { - id: generateId("test"), - email: "test@test.com", - name: "Test Customer", - appUserId: "test-app-user-id", - origin: CustomerOrigin.API, - environment: Environment.Production, +const customerInput: Omit = { + id: generateId('test'), + email: 'test@test.com', + name: 'Test Customer', + appUserId: 'test-app-user-id', + origin: CustomerOrigin.API, + environment: Environment.Production }; const expectedCustomer = { - customerId: customerInput.id, - email: customerInput.email, - name: customerInput.name, - origin: customerInput.origin, - appUserId: customerInput.appUserId, + customerId: customerInput.id, + email: customerInput.email, + name: customerInput.name, + origin: customerInput.origin, + appUserId: customerInput.appUserId }; -describe.sequential("/v1/customers/**", async () => { - test("GET /v1/customers - empty list", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('/v1/customers/**', () => { + test('GET /v1/customers - empty list', async (t) => { + const h = await IntegrationHarness.init(t); - const res = await h.get({ - url: "/v1/customers", - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: '/v1/customers', + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - expect(res.body).toEqual([]); - }); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + expect(res.body).toEqual([]); + }); - test("GET /v1/customers - customers", async (t) => { - const h = await IntegrationHarness.init(t); + test('GET /v1/customers - customers', async (t) => { + const h = await IntegrationHarness.init(t); - await h.db.primary.insert(customers).values({ - ...customerInput, - projectId: h.resources.project.id, - }); + await h.db.primary.insert(customers).values({ + ...customerInput, + projectId: h.resources.project.id + }); - const res = await h.get({ - url: "/v1/customers", - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: '/v1/customers', + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - expect(res.body).toStrictEqual([expectedCustomer]); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + expect(res.body).toStrictEqual([expectedCustomer]); - // Delete the customer - t.onTestFinished(async () => { - await h.db.primary - .delete(customers) - .where(eq(customers.id, customerInput.id)); - }); - }); + // Delete the customer + t.onTestFinished(async () => { + await h.db.primary + .delete(customers) + .where(eq(customers.id, customerInput.id)); + }); + }); }); diff --git a/apps/web/lib/api/v1/customers_listCustomers.ts b/apps/web/lib/api/v1/customers_listCustomers.ts index 17f7ff156..c28e9bb09 100644 --- a/apps/web/lib/api/v1/customers_listCustomers.ts +++ b/apps/web/lib/api/v1/customers_listCustomers.ts @@ -1,72 +1,72 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { customerResponseSchema } from "./schema"; -import { App } from "../hono/app"; -import { z } from "zod"; -import { createEffectHandler } from "@/lib/effect/runtimes/hono"; -import { CustomerService } from "@/lib/services/customer.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; +import { createEffectHandler } from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { CustomerService } from '@/lib/services/customer.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { customerResponseSchema } from './schema'; const route = describeRoute({ - description: "List customers", - operationId: "listCustomers", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: resolver(z.array(customerResponseSchema)), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Customers"], + description: 'List customers', + operationId: 'listCustomers', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(z.array(customerResponseSchema)) + } + } + }, + ...openApiErrorResponses + }, + tags: ['Customers'] }); export type Route = typeof route; export const registerCustomersListCustomers = (app: App) => - app.get("/v1/customers", route, async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const customerService = yield* CustomerService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); - const projectId = yield* authService.getAuthorizedProjectId(); - const customers = yield* Environment.provide(environment)( - customerService.getCustomers({ - projectId, - }) - ); + app.get('/v1/customers', route, async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const customerService = yield* CustomerService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + const projectId = yield* authService.getAuthorizedProjectId(); + const customers = yield* Environment.provide(environment)( + customerService.getCustomers({ + projectId + }) + ); - return c.json[]>( - customers.map((customer) => ({ - customerId: customer.id, - name: customer.name ?? null, - email: customer.email, - appUserId: customer.appUserId ?? null, - origin: customer.origin, - })) - ); - }) - ); - }) - ) - ); + return c.json[]>( + customers.map((customer) => ({ + customerId: customer.id, + name: customer.name ?? null, + email: customer.email, + appUserId: customer.appUserId ?? null, + origin: customer.origin + })) + ); + }) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/paywalls_createPaywall.test.ts b/apps/web/lib/api/v1/paywalls_createPaywall.test.ts index 213aa4506..5a0e9fd62 100644 --- a/apps/web/lib/api/v1/paywalls_createPaywall.test.ts +++ b/apps/web/lib/api/v1/paywalls_createPaywall.test.ts @@ -1,44 +1,44 @@ -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { paywalls } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { createPaywallBodySchema, paywallResponseSchema } from "./schema"; +import { paywalls } from '@voidhash/db'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { createPaywallBodySchema, paywallResponseSchema } from './schema'; -describe.sequential("/v1/paywalls", async () => { - test("POST /v1/paywalls - create paywall", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('/v1/paywalls', () => { + test('POST /v1/paywalls - create paywall', async (t) => { + const h = await IntegrationHarness.init(t); - const paywallInput: z.infer = { - name: `Test Paywall`, - }; + const paywallInput: z.infer = { + name: 'Test Paywall' + }; - const res = await h.post({ - url: "/v1/paywalls", - headers: { - "Content-Type": "application/json", - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - body: paywallInput, - }); + const res = await h.post({ + url: '/v1/paywalls', + headers: { + 'Content-Type': 'application/json', + 'x-secret-key': h.resources.secretKey.unhashedKey + }, + body: paywallInput + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer; + const responseBody = res.body as z.infer; - expect(responseBody.paywallId).toBeDefined(); - expect(responseBody.name).toBe(paywallInput.name); + expect(responseBody.paywallId).toBeDefined(); + expect(responseBody.name).toBe(paywallInput.name); - // Clean up the created paywall - t.onTestFinished(async () => { - if (responseBody?.paywallId) { - await h.db.primary - .delete(paywalls) - .where(eq(paywalls.id, responseBody.paywallId)); - } - }); - }); + // Clean up the created paywall + t.onTestFinished(async () => { + if (responseBody?.paywallId) { + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.id, responseBody.paywallId)); + } + }); + }); }); diff --git a/apps/web/lib/api/v1/paywalls_createPaywall.ts b/apps/web/lib/api/v1/paywalls_createPaywall.ts index 9be5784ae..7ef3d3da5 100644 --- a/apps/web/lib/api/v1/paywalls_createPaywall.ts +++ b/apps/web/lib/api/v1/paywalls_createPaywall.ts @@ -1,89 +1,87 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { createPaywallBodySchema, paywallResponseSchema } from "./schema"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; -import { z } from "zod"; -import { createEffectHandler } from "@/lib/effect/runtimes/hono"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; +import { NotFoundError } from '@/lib/effect/errors'; +import { createEffectHandler } from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; -import { NotFoundError } from "@/lib/effect/errors"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { createPaywallBodySchema, paywallResponseSchema } from './schema'; const route = describeRoute({ - description: "Create a new paywall", - operationId: "createPaywall", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(paywallResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Paywalls"], + description: 'Create a new paywall', + operationId: 'createPaywall', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(paywallResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['Paywalls'] }); export type Route = typeof route; export const registerPaywallsCreatePaywall = (app: App) => - app.post( - "/v1/paywalls", - route, - zValidator("json", createPaywallBodySchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const paywallService = yield* PaywallService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); + app.post( + '/v1/paywalls', + route, + zValidator('json', createPaywallBodySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const paywallService = yield* PaywallService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); - const projectId = yield* authService.getAuthorizedProjectId(); - const createdPaywall = yield* Environment.provide(environment)( - paywallService.createPaywall({ - name: c.req.valid("json").name, - projectId, - }), - ); + const projectId = yield* authService.getAuthorizedProjectId(); + const createdPaywall = yield* Environment.provide(environment)( + paywallService.createPaywall({ + name: c.req.valid('json').name, + projectId + }) + ); - const refreshedPaywall = yield* paywallService - .getPaywallById(createdPaywall.id) - .pipe( - Effect.catchTags({ - PaywallNotFoundError: (error) => - Effect.fail( - new NotFoundError({ message: error.message }), - ), - }), - ); + const refreshedPaywall = yield* paywallService + .getPaywallById(createdPaywall.id) + .pipe( + Effect.catchTags({ + PaywallNotFoundError: (error) => + Effect.fail(new NotFoundError({ message: error.message })) + }) + ); - if (!refreshedPaywall) { - // Should never happen, because the paywall was created above - return yield* Effect.die(new Error("Paywall not found")); - } + if (!refreshedPaywall) { + // Should never happen, because the paywall was created above + return yield* Effect.die(new Error('Paywall not found')); + } - return c.json>({ - paywallId: refreshedPaywall.id, - name: refreshedPaywall.name, - }); - }), - ); - }), - ), - ); + return c.json>({ + paywallId: refreshedPaywall.id, + name: refreshedPaywall.name + }); + }) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/paywalls_deletePaywall.test.ts b/apps/web/lib/api/v1/paywalls_deletePaywall.test.ts index 06016ca40..4a4de1377 100644 --- a/apps/web/lib/api/v1/paywalls_deletePaywall.test.ts +++ b/apps/web/lib/api/v1/paywalls_deletePaywall.test.ts @@ -1,62 +1,62 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { InsertPaywall, paywalls } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { Environment } from "@voidhash/lib/constants"; - -describe.sequential("/v1/paywalls/:paywallId", async () => { - test("DELETE /v1/paywalls/:paywallId - success", async (t) => { - const h = await IntegrationHarness.init(t); - - // Directly insert a paywall for testing - const paywallInput: Omit = { - id: generateId("test"), - name: "Paywall To Delete", - environment: Environment.Production, - }; - - await h.db.primary.insert(paywalls).values({ - ...paywallInput, - projectId: h.resources.project.id, - }); - - const res = await h.delete({ - url: `/v1/paywalls/${paywallInput.id}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); - - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - - expect(res.body).toEqual({ message: "Paywall deleted" }); - - // Verify the paywall is deleted from the database - const dbPaywall = await h.db.primary.query.paywalls.findFirst({ - where: eq(paywalls.id, paywallInput.id), - }); - expect(dbPaywall).toBeUndefined(); - }); - - test("DELETE /v1/paywalls/:paywallId - not found", async (t) => { - const h = await IntegrationHarness.init(t); - const nonExistentPaywallId = `non-existent-${generateId("test")}`; - - const res = await h.delete({ - url: `/v1/paywalls/${nonExistentPaywallId}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); - - // Assuming deletePaywall service handles not found gracefully (e.g., 404) - expect( - res.status, - `expected 404/500, received: ${JSON.stringify(res, null, 2)}` - ).toBe(404); - }); +import { type InsertPaywall, paywalls } from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; + +describe.sequential('/v1/paywalls/:paywallId', () => { + test('DELETE /v1/paywalls/:paywallId - success', async (t) => { + const h = await IntegrationHarness.init(t); + + // Directly insert a paywall for testing + const paywallInput: Omit = { + id: generateId('test'), + name: 'Paywall To Delete', + environment: Environment.Production + }; + + await h.db.primary.insert(paywalls).values({ + ...paywallInput, + projectId: h.resources.project.id + }); + + const res = await h.delete({ + url: `/v1/paywalls/${paywallInput.id}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + + expect(res.body).toEqual({ message: 'Paywall deleted' }); + + // Verify the paywall is deleted from the database + const dbPaywall = await h.db.primary.query.paywalls.findFirst({ + where: eq(paywalls.id, paywallInput.id) + }); + expect(dbPaywall).toBeUndefined(); + }); + + test('DELETE /v1/paywalls/:paywallId - not found', async (t) => { + const h = await IntegrationHarness.init(t); + const nonExistentPaywallId = `non-existent-${generateId('test')}`; + + const res = await h.delete({ + url: `/v1/paywalls/${nonExistentPaywallId}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); + + // Assuming deletePaywall service handles not found gracefully (e.g., 404) + expect( + res.status, + `expected 404/500, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + }); }); diff --git a/apps/web/lib/api/v1/paywalls_deletePaywall.ts b/apps/web/lib/api/v1/paywalls_deletePaywall.ts index 014a40d17..0eacb4dfc 100644 --- a/apps/web/lib/api/v1/paywalls_deletePaywall.ts +++ b/apps/web/lib/api/v1/paywalls_deletePaywall.ts @@ -1,80 +1,80 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { z } from "zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { deletePaywallParamsSchema } from "./schema"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { deletePaywallParamsSchema } from './schema'; const route = describeRoute({ - description: "Delete a paywall", - operationId: "deletePaywall", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: resolver(z.object({ message: z.string() })), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Paywalls"], + description: 'Delete a paywall', + operationId: 'deletePaywall', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(z.object({ message: z.string() })) + } + } + }, + ...openApiErrorResponses + }, + tags: ['Paywalls'] }); export type Route = typeof route; export const registerPaywallsDeletePaywall = (app: App) => - app.delete( - "/v1/paywalls/:paywallId", - route, - zValidator("param", deletePaywallParamsSchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const paywallService = yield* PaywallService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - yield* paywallService.deletePaywall({ - paywallId: c.req.param("paywallId"), - }); - return c.json({ message: "Paywall deleted" }); - }), - ).pipe( - Effect.catchTags({ - PaywallNotFoundError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }), - ), - PaywallInUseError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - originalError: error, - }), - ), - }), - ); - }), - ), - ); + app.delete( + '/v1/paywalls/:paywallId', + route, + zValidator('param', deletePaywallParamsSchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const paywallService = yield* PaywallService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + yield* paywallService.deletePaywall({ + paywallId: c.req.param('paywallId') + }); + return c.json({ message: 'Paywall deleted' }); + }) + ).pipe( + Effect.catchTags({ + PaywallNotFoundError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ), + PaywallInUseError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'BAD_REQUEST', + message: error.message, + originalError: error + }) + ) + }) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/paywalls_getPaywallById.test.ts b/apps/web/lib/api/v1/paywalls_getPaywallById.test.ts index e83df3651..55475025a 100644 --- a/apps/web/lib/api/v1/paywalls_getPaywallById.test.ts +++ b/apps/web/lib/api/v1/paywalls_getPaywallById.test.ts @@ -1,75 +1,75 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { InsertPaywall, paywalls } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { paywallResponseSchema } from "./schema"; -import { Environment } from "@voidhash/lib/constants"; +import { type InsertPaywall, paywalls } from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { paywallResponseSchema } from './schema'; -describe.sequential("/v1/paywalls/:paywallId", async () => { - test("GET /v1/paywalls/:paywallId - success", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('/v1/paywalls/:paywallId', () => { + test('GET /v1/paywalls/:paywallId - success', async (t) => { + const h = await IntegrationHarness.init(t); - // Directly insert a paywall for testing - const paywallInput: Omit = { - id: generateId("test"), - name: "Get Paywall By ID Test", - environment: Environment.Production, - }; + // Directly insert a paywall for testing + const paywallInput: Omit = { + id: generateId('test'), + name: 'Get Paywall By ID Test', + environment: Environment.Production + }; - await h.db.primary.insert(paywalls).values({ - ...paywallInput, - projectId: h.resources.project.id, - }); + await h.db.primary.insert(paywalls).values({ + ...paywallInput, + projectId: h.resources.project.id + }); - const res = await h.get({ - url: `/v1/paywalls/${paywallInput.id}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: `/v1/paywalls/${paywallInput.id}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer; + const responseBody = res.body as z.infer; - expect(responseBody.paywallId).toBe(paywallInput.id); - expect(responseBody.name).toBe(paywallInput.name); + expect(responseBody.paywallId).toBe(paywallInput.id); + expect(responseBody.name).toBe(paywallInput.name); - // Clean up the created paywall - t.onTestFinished(async () => { - await h.db.primary - .delete(paywalls) - .where(eq(paywalls.id, paywallInput.id)); - }); - }); + // Clean up the created paywall + t.onTestFinished(async () => { + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.id, paywallInput.id)); + }); + }); - test("GET /v1/paywalls/:paywallId - not found", async (t) => { - const h = await IntegrationHarness.init(t); - const nonExistentPaywallId = `non-existent-${generateId("test")}`; + test('GET /v1/paywalls/:paywallId - not found', async (t) => { + const h = await IntegrationHarness.init(t); + const nonExistentPaywallId = `non-existent-${generateId('test')}`; - const res = await h.get({ - url: `/v1/paywalls/${nonExistentPaywallId}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: `/v1/paywalls/${nonExistentPaywallId}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 404, received: ${JSON.stringify(res, null, 2)}` - ).toBe(404); - expect(res.body).toEqual({ - error: { - code: "NOT_FOUND", - docs: expect.any(String), - message: "Paywall not found", - requestId: expect.any(String), - }, - }); - }); + expect( + res.status, + `expected 404, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + expect(res.body).toEqual({ + error: { + code: 'NOT_FOUND', + docs: expect.any(String), + message: 'Paywall not found', + requestId: expect.any(String) + } + }); + }); }); diff --git a/apps/web/lib/api/v1/paywalls_getPaywallById.ts b/apps/web/lib/api/v1/paywalls_getPaywallById.ts index b1c42609d..a308c6ede 100644 --- a/apps/web/lib/api/v1/paywalls_getPaywallById.ts +++ b/apps/web/lib/api/v1/paywalls_getPaywallById.ts @@ -1,74 +1,73 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { z } from "zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { getPaywallByIdParamsSchema, paywallResponseSchema } from "./schema"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { getPaywallByIdParamsSchema, paywallResponseSchema } from './schema'; const route = describeRoute({ - description: "Get a paywall", - operationId: "getPaywallById", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(paywallResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Paywalls"], + description: 'Get a paywall', + operationId: 'getPaywallById', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(paywallResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['Paywalls'] }); export type Route = typeof route; export const registerPaywallsGetPaywallById = (app: App) => - app.get( - "/v1/paywalls/:paywallId", - route, - zValidator("param", getPaywallByIdParamsSchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - console.log("getPaywallById 2"); - const paywallService = yield* PaywallService; - const paywall = yield* paywallService - .getPaywallById(c.req.param("paywallId")) - .pipe( - Effect.catchTags({ - PaywallNotFoundError: () => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: "Paywall not found", - }), - ), - }), - ); + app.get( + '/v1/paywalls/:paywallId', + route, + zValidator('param', getPaywallByIdParamsSchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + const paywall = yield* paywallService + .getPaywallById(c.req.param('paywallId')) + .pipe( + Effect.catchTags({ + PaywallNotFoundError: () => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: 'Paywall not found' + }) + ) + }) + ); - return c.json>({ - paywallId: paywall.id, - name: paywall.name, - }); - }), - ); - }), - ), - ); + return c.json>({ + paywallId: paywall.id, + name: paywall.name + }); + }) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/paywalls_getPaywallProducts.test.ts b/apps/web/lib/api/v1/paywalls_getPaywallProducts.test.ts index 97bb1350b..a376b1ab1 100644 --- a/apps/web/lib/api/v1/paywalls_getPaywallProducts.test.ts +++ b/apps/web/lib/api/v1/paywalls_getPaywallProducts.test.ts @@ -1,145 +1,145 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; import { - InsertPaywall, - paywalls, - InsertProduct, - products, - paywallProducts, - InsertPaywallProduct, -} from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { paywallProductResponseSchema } from "./schema"; -import { Environment } from "@voidhash/lib/constants"; - -describe.sequential("/v1/paywalls/:paywallId/products", async () => { - test("GET /v1/paywalls/:paywallId/products - success", async (t) => { - const h = await IntegrationHarness.init(t); - - // Create paywall - const paywallInput: Omit = { - id: generateId("test"), - name: "Paywall for Get Products", - environment: Environment.Production, - }; - await h.db.primary.insert(paywalls).values({ - ...paywallInput, - projectId: h.resources.project.id, - }); - - // Create product - const productInput: Omit = { - id: generateId("test"), - name: "Product Attached to Paywall", - environment: Environment.Production, - }; - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); - - // Link product to paywall - const linkInput: InsertPaywallProduct = { - id: generateId("test"), - paywallId: paywallInput.id, - productId: productInput.id, - }; - await h.db.primary.insert(paywallProducts).values(linkInput); - - const res = await h.get({ - url: `/v1/paywalls/${paywallInput.id}/products`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); - - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - - const responseBody = res.body as z.infer< - typeof paywallProductResponseSchema - >[]; - expect(responseBody).toHaveLength(1); - expect(responseBody[0]!.paywallId).toBe(paywallInput.id); - expect(responseBody[0]!.productId).toBe(productInput.id); - expect(responseBody[0]!.productName).toBe(productInput.name); - - // Clean up - t.onTestFinished(async () => { - await h.db.primary - .delete(paywallProducts) - .where(eq(paywallProducts.id, linkInput.id)); - await h.db.primary - .delete(products) - .where(eq(products.id, productInput.id)); - await h.db.primary - .delete(paywalls) - .where(eq(paywalls.id, paywallInput.id)); - }); - }); - - test("GET /v1/paywalls/:paywallId/products - empty list", async (t) => { - const h = await IntegrationHarness.init(t); - - // Create paywall without products - const paywallInput: Omit = { - id: generateId("test"), - name: "Empty Paywall", - environment: Environment.Production, - }; - await h.db.primary.insert(paywalls).values({ - ...paywallInput, - projectId: h.resources.project.id, - }); - - const res = await h.get({ - url: `/v1/paywalls/${paywallInput.id}/products`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); - - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - expect(res.body).toEqual([]); - - // Clean up - t.onTestFinished(async () => { - await h.db.primary - .delete(paywalls) - .where(eq(paywalls.id, paywallInput.id)); - }); - }); - - test("GET /v1/paywalls/:paywallId/products - paywall not found", async (t) => { - const h = await IntegrationHarness.init(t); - const nonExistentPaywallId = `non-existent-${generateId("test")}`; - - const res = await h.get({ - url: `/v1/paywalls/${nonExistentPaywallId}/products`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); - - // Service likely returns empty array for non-existent paywall - expect( - res.status, - `expected 404, received: ${JSON.stringify(res, null, 2)}` - ).toBe(404); - expect(res.body).toEqual({ - error: { - code: "NOT_FOUND", - docs: expect.any(String), - message: "Paywall " + nonExistentPaywallId + " not found", - requestId: expect.any(String), - }, - }); - }); + type InsertPaywall, + type InsertPaywallProduct, + type InsertProduct, + paywallProducts, + paywalls, + products +} from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { paywallProductResponseSchema } from './schema'; + +describe.sequential('/v1/paywalls/:paywallId/products', () => { + test('GET /v1/paywalls/:paywallId/products - success', async (t) => { + const h = await IntegrationHarness.init(t); + + // Create paywall + const paywallInput: Omit = { + id: generateId('test'), + name: 'Paywall for Get Products', + environment: Environment.Production + }; + await h.db.primary.insert(paywalls).values({ + ...paywallInput, + projectId: h.resources.project.id + }); + + // Create product + const productInput: Omit = { + id: generateId('test'), + name: 'Product Attached to Paywall', + environment: Environment.Production + }; + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); + + // Link product to paywall + const linkInput: InsertPaywallProduct = { + id: generateId('test'), + paywallId: paywallInput.id, + productId: productInput.id + }; + await h.db.primary.insert(paywallProducts).values(linkInput); + + const res = await h.get({ + url: `/v1/paywalls/${paywallInput.id}/products`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + + const responseBody = res.body as z.infer< + typeof paywallProductResponseSchema + >[]; + expect(responseBody).toHaveLength(1); + expect(responseBody[0]?.paywallId).toBe(paywallInput.id); + expect(responseBody[0]?.productId).toBe(productInput.id); + expect(responseBody[0]?.productName).toBe(productInput.name); + + // Clean up + t.onTestFinished(async () => { + await h.db.primary + .delete(paywallProducts) + .where(eq(paywallProducts.id, linkInput.id)); + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.id, paywallInput.id)); + }); + }); + + test('GET /v1/paywalls/:paywallId/products - empty list', async (t) => { + const h = await IntegrationHarness.init(t); + + // Create paywall without products + const paywallInput: Omit = { + id: generateId('test'), + name: 'Empty Paywall', + environment: Environment.Production + }; + await h.db.primary.insert(paywalls).values({ + ...paywallInput, + projectId: h.resources.project.id + }); + + const res = await h.get({ + url: `/v1/paywalls/${paywallInput.id}/products`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + expect(res.body).toEqual([]); + + // Clean up + t.onTestFinished(async () => { + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.id, paywallInput.id)); + }); + }); + + test('GET /v1/paywalls/:paywallId/products - paywall not found', async (t) => { + const h = await IntegrationHarness.init(t); + const nonExistentPaywallId = `non-existent-${generateId('test')}`; + + const res = await h.get({ + url: `/v1/paywalls/${nonExistentPaywallId}/products`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); + + // Service likely returns empty array for non-existent paywall + expect( + res.status, + `expected 404, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + expect(res.body).toEqual({ + error: { + code: 'NOT_FOUND', + docs: expect.any(String), + message: `Paywall ${nonExistentPaywallId} not found`, + requestId: expect.any(String) + } + }); + }); }); diff --git a/apps/web/lib/api/v1/paywalls_getPaywallProducts.ts b/apps/web/lib/api/v1/paywalls_getPaywallProducts.ts index eed8888d1..e69e20bd1 100644 --- a/apps/web/lib/api/v1/paywalls_getPaywallProducts.ts +++ b/apps/web/lib/api/v1/paywalls_getPaywallProducts.ts @@ -1,75 +1,73 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { z } from "zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; +import { NotFoundError } from '@/lib/effect/errors'; +import { createEffectHandler } from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; import { - getPaywallProductsParamsSchema, - paywallProductResponseSchema, -} from "./schema"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; -import { createEffectHandler } from "@/lib/effect/runtimes/hono"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; -import { NotFoundError } from "@/lib/effect/errors"; + getPaywallProductsParamsSchema, + paywallProductResponseSchema +} from './schema'; const route = describeRoute({ - description: "Get all products for a paywall", - operationId: "getPaywallProducts", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: resolver(z.array(paywallProductResponseSchema)), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Paywalls"], + description: 'Get all products for a paywall', + operationId: 'getPaywallProducts', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(z.array(paywallProductResponseSchema)) + } + } + }, + ...openApiErrorResponses + }, + tags: ['Paywalls'] }); export type Route = typeof route; export const registerPaywallsGetPaywallProducts = (app: App) => - app.get( - "/v1/paywalls/:paywallId/products", - route, - zValidator("param", getPaywallProductsParamsSchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const paywallService = yield* PaywallService; - const paywallProducts = yield* paywallService - .getPaywallProducts(c.req.param("paywallId")) - .pipe( - Effect.catchTags({ - PaywallNotFoundError: (error) => - Effect.fail( - new NotFoundError({ message: error.message }), - ), - }), - ); - return c.json[]>( - paywallProducts.map((pp) => ({ - paywallId: pp.paywallId, - productId: pp.productId, - productName: pp.product.name ?? null, - })), - ); - }), - ); - }), - ), - ); + app.get( + '/v1/paywalls/:paywallId/products', + route, + zValidator('param', getPaywallProductsParamsSchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + const paywallProducts = yield* paywallService + .getPaywallProducts(c.req.param('paywallId')) + .pipe( + Effect.catchTags({ + PaywallNotFoundError: (error) => + Effect.fail(new NotFoundError({ message: error.message })) + }) + ); + return c.json[]>( + paywallProducts.map((pp) => ({ + paywallId: pp.paywallId, + productId: pp.productId, + productName: pp.product.name ?? null + })) + ); + }) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/paywalls_listPaywalls.test.ts b/apps/web/lib/api/v1/paywalls_listPaywalls.test.ts index 6d9a16b62..9408a9ee5 100644 --- a/apps/web/lib/api/v1/paywalls_listPaywalls.test.ts +++ b/apps/web/lib/api/v1/paywalls_listPaywalls.test.ts @@ -1,71 +1,71 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { InsertPaywall, paywalls } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { paywallResponseSchema } from "./schema"; -import { Environment } from "@voidhash/lib/constants"; +import { type InsertPaywall, paywalls } from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { paywallResponseSchema } from './schema'; -const paywallInput: Omit = { - id: generateId("test"), - name: "Test Paywall for List", - environment: Environment.Production, +const paywallInput: Omit = { + id: generateId('test'), + name: 'Test Paywall for List', + environment: Environment.Production }; const expectedPaywall: z.infer = { - paywallId: paywallInput.id, - name: paywallInput.name, + paywallId: paywallInput.id, + name: paywallInput.name }; -describe.sequential("/v1/paywalls/**", async () => { - test("GET /v1/paywalls - empty list", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('/v1/paywalls/**', () => { + test('GET /v1/paywalls - empty list', async (t) => { + const h = await IntegrationHarness.init(t); - const res = await h.get({ - url: "/v1/paywalls", - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: '/v1/paywalls', + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - expect(res.body).toEqual([]); - }); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + expect(res.body).toEqual([]); + }); - test("GET /v1/paywalls - paywalls", async (t) => { - const h = await IntegrationHarness.init(t); + test('GET /v1/paywalls - paywalls', async (t) => { + const h = await IntegrationHarness.init(t); - await h.db.primary.insert(paywalls).values({ - ...paywallInput, - projectId: h.resources.project.id, - }); + await h.db.primary.insert(paywalls).values({ + ...paywallInput, + projectId: h.resources.project.id + }); - const res = await h.get({ - url: "/v1/paywalls", - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: '/v1/paywalls', + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer[]; - expect(responseBody).toStrictEqual([ - { ...expectedPaywall, projectId: h.resources.project.id }, - ]); + const responseBody = res.body as z.infer[]; + expect(responseBody).toStrictEqual([ + { ...expectedPaywall, projectId: h.resources.project.id } + ]); - // Delete the paywall - t.onTestFinished(async () => { - await h.db.primary - .delete(paywalls) - .where(eq(paywalls.id, paywallInput.id)); - }); - }); + // Delete the paywall + t.onTestFinished(async () => { + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.id, paywallInput.id)); + }); + }); }); diff --git a/apps/web/lib/api/v1/paywalls_listPaywalls.ts b/apps/web/lib/api/v1/paywalls_listPaywalls.ts index fbbe9b15a..9ce28abf0 100644 --- a/apps/web/lib/api/v1/paywalls_listPaywalls.ts +++ b/apps/web/lib/api/v1/paywalls_listPaywalls.ts @@ -1,68 +1,68 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { z } from "zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { paywallResponseSchema } from "./schema"; -import { App } from "../hono/app"; -import { createEffectHandler } from "@/lib/effect/runtimes/hono"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; +import { createEffectHandler } from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { paywallResponseSchema } from './schema'; const route = describeRoute({ - description: "List paywalls", - operationId: "listPaywalls", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: resolver(z.array(paywallResponseSchema)), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Paywalls"], + description: 'List paywalls', + operationId: 'listPaywalls', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(z.array(paywallResponseSchema)) + } + } + }, + ...openApiErrorResponses + }, + tags: ['Paywalls'] }); export type Route = typeof route; export const registerPaywallsListPaywalls = (app: App) => - app.get("/v1/paywalls", route, async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const paywallService = yield* PaywallService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); - const projectId = yield* authService.getAuthorizedProjectId(); - const paywalls = yield* Environment.provide(environment)( - paywallService.getPaywalls(projectId) - ); + app.get('/v1/paywalls', route, async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const paywallService = yield* PaywallService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + const projectId = yield* authService.getAuthorizedProjectId(); + const paywalls = yield* Environment.provide(environment)( + paywallService.getPaywalls(projectId) + ); - return c.json[]>( - paywalls.map((paywall) => ({ - paywallId: paywall.id, - name: paywall.name, - projectId: paywall.projectId, - })) - ); - }) - ); - }) - ) - ); + return c.json[]>( + paywalls.map((paywall) => ({ + paywallId: paywall.id, + name: paywall.name, + projectId: paywall.projectId + })) + ); + }) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/products_attachProviderProduct.test.ts b/apps/web/lib/api/v1/products_attachProviderProduct.test.ts index 5c600be3e..d10b3b767 100644 --- a/apps/web/lib/api/v1/products_attachProviderProduct.test.ts +++ b/apps/web/lib/api/v1/products_attachProviderProduct.test.ts @@ -1,112 +1,112 @@ -import { generateId } from "@/lib/id/generate"; -import { and, eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; import { - InsertProduct, - paymentProviderConfigurationProducts, - products, -} from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { - attachProviderProductBodySchema, - providerProductResponseSchema, -} from "./schema"; -import { stripe } from "@/lib/payment-providers/stripe/stripe"; -import { Environment } from "@voidhash/lib/constants"; + type InsertProduct, + paymentProviderConfigurationProducts, + products +} from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { and, eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import type { stripe } from '@/lib/payment-providers/stripe/stripe'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { + attachProviderProductBodySchema, + providerProductResponseSchema +} from './schema'; -describe.sequential("/v1/products/:productId/provider-products", async () => { - test("POST /v1/products/:productId/provider-products - success", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('/v1/products/:productId/provider-products', () => { + test('POST /v1/products/:productId/provider-products - success', async (t) => { + const h = await IntegrationHarness.init(t); - // Create a base product - const productInput: Omit = { - id: generateId("test"), - name: "Base Product for Provider", - environment: Environment.Production, - }; - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); + // Create a base product + const productInput: Omit = { + id: generateId('test'), + name: 'Base Product for Provider', + environment: Environment.Production + }; + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); - // Define provider product input (assuming Stripe for now) - const providerProductInput: z.infer< - typeof attachProviderProductBodySchema - > = { - providerId: "stripe", - paymentProviderConfigurationId: - h.resources.paymentProviderConfiguration.id, - // These fields depend heavily on the Stripe configuration schema - configuration: { - productId: `prod_${generateId("test")}`, - // @ts-expect-error - TODO: fix this - priceId: `price_${generateId("test")}`, - } satisfies z.infer< - ReturnType - >, - }; + // Define provider product input (assuming Stripe for now) + const providerProductInput: z.infer< + typeof attachProviderProductBodySchema + > = { + providerId: 'stripe', + paymentProviderConfigurationId: + h.resources.paymentProviderConfiguration.id, + // These fields depend heavily on the Stripe configuration schema + configuration: { + productId: `prod_${generateId('test')}`, + // @ts-expect-error - TODO: fix this + priceId: `price_${generateId('test')}` + } satisfies z.infer< + ReturnType + > + }; - const res = await h.post({ - url: `/v1/products/${productInput.id}/provider-products`, - headers: { - "Content-Type": "application/json", - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - body: providerProductInput, - }); + const res = await h.post({ + url: `/v1/products/${productInput.id}/provider-products`, + headers: { + 'Content-Type': 'application/json', + 'x-secret-key': h.resources.secretKey.unhashedKey + }, + body: providerProductInput + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}`, - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer< - typeof providerProductResponseSchema - >; + const responseBody = res.body as z.infer< + typeof providerProductResponseSchema + >; - expect(responseBody.providerProductKey).toBeDefined(); // Key might be auto-generated or based on input - expect( - responseBody.providerConfiguration.paymentProviderConfigurationId, - ).toBe(providerProductInput.paymentProviderConfigurationId); - expect(responseBody.providerConfiguration.configuration).toEqual( - providerProductInput.configuration, - ); + expect(responseBody.providerProductKey).toBeDefined(); // Key might be auto-generated or based on input + expect( + responseBody.providerConfiguration.paymentProviderConfigurationId + ).toBe(providerProductInput.paymentProviderConfigurationId); + expect(responseBody.providerConfiguration.configuration).toEqual( + providerProductInput.configuration + ); - // Verify in DB - const dbProviderProduct = - await h.db.primary.query.paymentProviderConfigurationProducts.findFirst({ - where: and( - eq(paymentProviderConfigurationProducts.productId, productInput.id), - eq( - paymentProviderConfigurationProducts.providerProductKey, - responseBody.providerProductKey, - ), - ), - }); - expect(dbProviderProduct).toBeDefined(); - expect(dbProviderProduct?.paymentProviderConfigurationId).toBe( - providerProductInput.paymentProviderConfigurationId, - ); - expect(dbProviderProduct?.configuration).toEqual( - providerProductInput.configuration, - ); + // Verify in DB + const dbProviderProduct = + await h.db.primary.query.paymentProviderConfigurationProducts.findFirst({ + where: and( + eq(paymentProviderConfigurationProducts.productId, productInput.id), + eq( + paymentProviderConfigurationProducts.providerProductKey, + responseBody.providerProductKey + ) + ) + }); + expect(dbProviderProduct).toBeDefined(); + expect(dbProviderProduct?.paymentProviderConfigurationId).toBe( + providerProductInput.paymentProviderConfigurationId + ); + expect(dbProviderProduct?.configuration).toEqual( + providerProductInput.configuration + ); - // Clean up - t.onTestFinished(async () => { - await h.db.primary - .delete(paymentProviderConfigurationProducts) - .where( - eq( - paymentProviderConfigurationProducts.providerProductKey, - responseBody.providerProductKey, - ), - ); - await h.db.primary - .delete(products) - .where(eq(products.id, productInput.id)); - }); - }); + // Clean up + t.onTestFinished(async () => { + await h.db.primary + .delete(paymentProviderConfigurationProducts) + .where( + eq( + paymentProviderConfigurationProducts.providerProductKey, + responseBody.providerProductKey + ) + ); + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + }); + }); - // TODO: Add tests for invalid providerId, missing configuration, non-existent productId etc. + // TODO: Add tests for invalid providerId, missing configuration, non-existent productId etc. }); diff --git a/apps/web/lib/api/v1/products_attachProviderProduct.ts b/apps/web/lib/api/v1/products_attachProviderProduct.ts index 61c3e80f1..e044c71ab 100644 --- a/apps/web/lib/api/v1/products_attachProviderProduct.ts +++ b/apps/web/lib/api/v1/products_attachProviderProduct.ts @@ -1,124 +1,124 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - attachProviderProductBodySchema, - attachProviderProductParamsSchema, - providerProductResponseSchema, -} from "./schema"; -import { z } from "zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { ProductService } from "@/lib/services/product.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { ProductService } from '@/lib/services/product.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + attachProviderProductBodySchema, + attachProviderProductParamsSchema, + providerProductResponseSchema +} from './schema'; const route = describeRoute({ - description: "Attach a new provider product", - operationId: "attachProviderProduct", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: resolver(providerProductResponseSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Products"], + description: 'Attach a new provider product', + operationId: 'attachProviderProduct', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(providerProductResponseSchema) + } + } + }, + ...openApiErrorResponses + }, + tags: ['Products'] }); export type Route = typeof route; export const registerProductsAttachProviderProduct = (app: App) => - app.post( - "/v1/products/:productId/provider-products", - route, - zValidator("param", attachProviderProductParamsSchema), - zValidator("json", attachProviderProductBodySchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const productService = yield* ProductService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environmentService = yield* EnvironmentService; - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); - const result = yield* Environment.provide(environment)( - productService.createPaymentProviderProduct({ - productId: c.req.param("productId"), - paymentProviderConfigurationId: - c.req.valid("json").paymentProviderConfigurationId, - configuration: c.req.valid("json").configuration, - }), - ).pipe( - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }), - ), - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }), - ), - PaymentProviderNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }), - ), - InvalidConfiguration: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - originalError: error, - }), - ), - }), - ); - return c.json>({ - providerProductKey: result.providerProductKey, - // @ts-expect-error - TODO: fix this - providerConfiguration: { - paymentProviderConfigurationId: - result.paymentProviderConfigurationId, - configuration: result.configuration, - }, - }); - }), - ); - }), - ), - ); + app.post( + '/v1/products/:productId/provider-products', + route, + zValidator('param', attachProviderProductParamsSchema), + zValidator('json', attachProviderProductBodySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const productService = yield* ProductService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environmentService = yield* EnvironmentService; + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + const result = yield* Environment.provide(environment)( + productService.createPaymentProviderProduct({ + productId: c.req.param('productId'), + paymentProviderConfigurationId: + c.req.valid('json').paymentProviderConfigurationId, + configuration: c.req.valid('json').configuration + }) + ).pipe( + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ), + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ), + PaymentProviderNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ), + InvalidConfiguration: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'BAD_REQUEST', + message: error.message, + originalError: error + }) + ) + }) + ); + return c.json>({ + providerProductKey: result.providerProductKey, + // @ts-expect-error - TODO: fix this + providerConfiguration: { + paymentProviderConfigurationId: + result.paymentProviderConfigurationId, + configuration: result.configuration + } + }); + }) + ); + }) + ) + ); export type RouteResponse = z.infer; export type RouteRequest = z.infer; diff --git a/apps/web/lib/api/v1/products_createProduct.test.ts b/apps/web/lib/api/v1/products_createProduct.test.ts index 1f702ab0d..fb66faafd 100644 --- a/apps/web/lib/api/v1/products_createProduct.test.ts +++ b/apps/web/lib/api/v1/products_createProduct.test.ts @@ -1,44 +1,44 @@ -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { products } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { createProductBodySchema, productResponseSchema } from "./schema"; +import { products } from '@voidhash/db'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { createProductBodySchema, productResponseSchema } from './schema'; -describe.sequential("/v1/products", async () => { - test("POST /v1/products - create product", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('/v1/products', () => { + test('POST /v1/products - create product', async (t) => { + const h = await IntegrationHarness.init(t); - const productInput: z.infer = { - name: `Test Product}`, - }; + const productInput: z.infer = { + name: 'Test Product}' + }; - const res = await h.post({ - url: "/v1/products", - headers: { - "Content-Type": "application/json", - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - body: productInput, - }); + const res = await h.post({ + url: '/v1/products', + headers: { + 'Content-Type': 'application/json', + 'x-secret-key': h.resources.secretKey.unhashedKey + }, + body: productInput + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer; + const responseBody = res.body as z.infer; - expect(responseBody.productId).toBeDefined(); - expect(responseBody.name).toBe(productInput.name); + expect(responseBody.productId).toBeDefined(); + expect(responseBody.name).toBe(productInput.name); - // Clean up the created product - t.onTestFinished(async () => { - if (responseBody?.productId) { - await h.db.primary - .delete(products) - .where(eq(products.id, responseBody.productId)); - } - }); - }); + // Clean up the created product + t.onTestFinished(async () => { + if (responseBody?.productId) { + await h.db.primary + .delete(products) + .where(eq(products.id, responseBody.productId)); + } + }); + }); }); diff --git a/apps/web/lib/api/v1/products_createProduct.ts b/apps/web/lib/api/v1/products_createProduct.ts index bf76c57c3..2fed42e79 100644 --- a/apps/web/lib/api/v1/products_createProduct.ts +++ b/apps/web/lib/api/v1/products_createProduct.ts @@ -1,92 +1,92 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { z } from "zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { createProductBodySchema, productResponseSchema } from "./schema"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { ProductService } from "@/lib/services/product.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { ProductService } from '@/lib/services/product.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { createProductBodySchema, productResponseSchema } from './schema'; const route = describeRoute({ - description: "Create a new product", - operationId: "createProduct", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(productResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Products"], + description: 'Create a new product', + operationId: 'createProduct', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(productResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['Products'] }); export type Route = typeof route; export const registerProductsCreateProduct = (app: App) => - app.post( - "/v1/products", - route, - zValidator("json", createProductBodySchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const productService = yield* ProductService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); - const projectId = yield* authService.getAuthorizedProjectId(); - const product = yield* Environment.provide(environment)( - productService - .createProduct({ - name: c.req.valid("json").name, - projectId, - }) - .pipe( - Effect.flatMap((createdProduct) => - productService.getProductById(createdProduct.id) - ), - Effect.catchTags({ - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - originalError: error, - }) - ), - }) - ) - ); - return c.json>({ - productId: product.id, - name: product.name, - }); - }) - ); - }) - ) - ); + app.post( + '/v1/products', + route, + zValidator('json', createProductBodySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const productService = yield* ProductService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + const projectId = yield* authService.getAuthorizedProjectId(); + const product = yield* Environment.provide(environment)( + productService + .createProduct({ + name: c.req.valid('json').name, + projectId + }) + .pipe( + Effect.flatMap((createdProduct) => + productService.getProductById(createdProduct.id) + ), + Effect.catchTags({ + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'BAD_REQUEST', + message: error.message, + originalError: error + }) + ) + }) + ) + ); + return c.json>({ + productId: product.id, + name: product.name + }); + }) + ); + }) + ) + ); export type RouteResponse = z.infer; export type RouteRequest = z.infer; diff --git a/apps/web/lib/api/v1/products_deleteProduct.test.ts b/apps/web/lib/api/v1/products_deleteProduct.test.ts index b29dce5a2..4958441f6 100644 --- a/apps/web/lib/api/v1/products_deleteProduct.test.ts +++ b/apps/web/lib/api/v1/products_deleteProduct.test.ts @@ -1,62 +1,62 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { InsertProduct, products } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { Environment } from "@voidhash/lib/constants"; - -describe.sequential("/v1/products/:productId", async () => { - test("DELETE /v1/products/:productId - success", async (t) => { - const h = await IntegrationHarness.init(t); - - // Directly insert a product for testing - const productInput: Omit = { - id: generateId("test"), - name: "Product To Delete", - environment: Environment.Production, - }; - - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); - - const res = await h.delete({ - url: `/v1/products/${productInput.id}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); - - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - - expect(res.body).toEqual({ message: "Product deleted" }); - - // Verify the product is deleted from the database - const dbProduct = await h.db.primary.query.products.findFirst({ - where: eq(products.id, productInput.id), - }); - expect(dbProduct).toBeUndefined(); - }); - - test("DELETE /v1/products/:productId - not found", async (t) => { - const h = await IntegrationHarness.init(t); - const nonExistentProductId = `non-existent-${generateId("test")}`; - - const res = await h.delete({ - url: `/v1/products/${nonExistentProductId}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); - - // Assuming deleteProduct service handles not found gracefully (e.g., 404) - expect( - res.status, - `expected 404/500, received: ${JSON.stringify(res, null, 2)}` - ).toBe(404); - }); +import { type InsertProduct, products } from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; + +describe.sequential('/v1/products/:productId', () => { + test('DELETE /v1/products/:productId - success', async (t) => { + const h = await IntegrationHarness.init(t); + + // Directly insert a product for testing + const productInput: Omit = { + id: generateId('test'), + name: 'Product To Delete', + environment: Environment.Production + }; + + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); + + const res = await h.delete({ + url: `/v1/products/${productInput.id}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + + expect(res.body).toEqual({ message: 'Product deleted' }); + + // Verify the product is deleted from the database + const dbProduct = await h.db.primary.query.products.findFirst({ + where: eq(products.id, productInput.id) + }); + expect(dbProduct).toBeUndefined(); + }); + + test('DELETE /v1/products/:productId - not found', async (t) => { + const h = await IntegrationHarness.init(t); + const nonExistentProductId = `non-existent-${generateId('test')}`; + + const res = await h.delete({ + url: `/v1/products/${nonExistentProductId}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); + + // Assuming deleteProduct service handles not found gracefully (e.g., 404) + expect( + res.status, + `expected 404/500, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + }); }); diff --git a/apps/web/lib/api/v1/products_deleteProduct.ts b/apps/web/lib/api/v1/products_deleteProduct.ts index 3fc7e0e17..995ab8754 100644 --- a/apps/web/lib/api/v1/products_deleteProduct.ts +++ b/apps/web/lib/api/v1/products_deleteProduct.ts @@ -1,74 +1,74 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { deleteProductParamsSchema } from "./schema"; -import { z } from "zod"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { ProductService } from "@/lib/services/product.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { ProductService } from '@/lib/services/product.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { deleteProductParamsSchema } from './schema'; const route = describeRoute({ - description: "Delete a product", - operationId: "deleteProduct", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: resolver(z.object({ message: z.string() })), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Products"], + description: 'Delete a product', + operationId: 'deleteProduct', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(z.object({ message: z.string() })) + } + } + }, + ...openApiErrorResponses + }, + tags: ['Products'] }); export type Route = typeof route; export const registerProductsDeleteProduct = (app: App) => - app.delete( - "/v1/products/:productId", - route, - zValidator("param", deleteProductParamsSchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const productService = yield* ProductService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - yield* productService - .deleteProduct({ - productId: c.req.param("productId"), - }) - .pipe( - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }) - ), - }) - ); - return c.json({ message: "Product deleted" }); - }) - ); - }) - ) - ); + app.delete( + '/v1/products/:productId', + route, + zValidator('param', deleteProductParamsSchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const productService = yield* ProductService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + yield* productService + .deleteProduct({ + productId: c.req.param('productId') + }) + .pipe( + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ) + }) + ); + return c.json({ message: 'Product deleted' }); + }) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/products_deleteProviderProduct.test.ts b/apps/web/lib/api/v1/products_deleteProviderProduct.test.ts index d2475be9c..0ca85e9e1 100644 --- a/apps/web/lib/api/v1/products_deleteProviderProduct.test.ts +++ b/apps/web/lib/api/v1/products_deleteProviderProduct.test.ts @@ -1,106 +1,106 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; import { - InsertProduct, - paymentProviderConfigurationProducts, - InsertPaymentProviderConfigurationProduct, - products, -} from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { stripe } from "@/lib/payment-providers/stripe/stripe"; -import { Environment } from "@voidhash/lib/constants"; + type InsertPaymentProviderConfigurationProduct, + type InsertProduct, + paymentProviderConfigurationProducts, + products +} from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import type { stripe } from '@/lib/payment-providers/stripe/stripe'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; describe.sequential( - "/v1/products/:productId/provider-products/:providerId/:providerProductKey", - async () => { - test("DELETE /v1/products/:productId/provider-products/:providerId/:providerProductKey - success", async (t) => { - const h = await IntegrationHarness.init(t); + '/v1/products/:productId/provider-products/:providerId/:providerProductKey', + () => { + test('DELETE /v1/products/:productId/provider-products/:providerId/:providerProductKey - success', async (t) => { + const h = await IntegrationHarness.init(t); - // Create a base product - const productInput: Omit = { - id: generateId("test"), - name: "Base Product for Delete Provider", - environment: Environment.Production, - }; - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); + // Create a base product + const productInput: Omit = { + id: generateId('test'), + name: 'Base Product for Delete Provider', + environment: Environment.Production + }; + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); - // Directly insert a provider product to delete - const providerConfigToDelete: InsertPaymentProviderConfigurationProduct = - { - id: generateId("test"), - productId: productInput.id, - paymentProviderConfigurationId: - h.resources.paymentProviderConfiguration.id, - providerProductKey: `ppk_to_delete_${generateId("test")}`, - configuration: { - priceId: `price_to_delete_${generateId("test")}`, - productId: `prod_to_delete_${generateId("test")}`, - } satisfies z.infer< - ReturnType - >, - isActive: true, - }; - await h.db.primary - .insert(paymentProviderConfigurationProducts) - .values(providerConfigToDelete); + // Directly insert a provider product to delete + const providerConfigToDelete: InsertPaymentProviderConfigurationProduct = + { + id: generateId('test'), + productId: productInput.id, + paymentProviderConfigurationId: + h.resources.paymentProviderConfiguration.id, + providerProductKey: `ppk_to_delete_${generateId('test')}`, + configuration: { + priceId: `price_to_delete_${generateId('test')}`, + productId: `prod_to_delete_${generateId('test')}` + } satisfies z.infer< + ReturnType + >, + isActive: true + }; + await h.db.primary + .insert(paymentProviderConfigurationProducts) + .values(providerConfigToDelete); - const res = await h.delete({ - url: `/v1/products/${productInput.id}/provider-products/${providerConfigToDelete.paymentProviderConfigurationId}/${providerConfigToDelete.providerProductKey}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.delete({ + url: `/v1/products/${productInput.id}/provider-products/${providerConfigToDelete.paymentProviderConfigurationId}/${providerConfigToDelete.providerProductKey}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - expect(res.body).toEqual({ message: "Provider product deleted" }); + expect(res.body).toEqual({ message: 'Provider product deleted' }); - // Verify deletion in DB - const dbProviderProduct = - await h.db.primary.query.paymentProviderConfigurationProducts.findFirst( - { - where: eq( - paymentProviderConfigurationProducts.id, - providerConfigToDelete.id - ), - } - ); - expect(dbProviderProduct).toBeUndefined(); + // Verify deletion in DB + const dbProviderProduct = + await h.db.primary.query.paymentProviderConfigurationProducts.findFirst( + { + where: eq( + paymentProviderConfigurationProducts.id, + providerConfigToDelete.id + ) + } + ); + expect(dbProviderProduct).toBeUndefined(); - // Clean up base product - t.onTestFinished(async () => { - await h.db.primary - .delete(products) - .where(eq(products.id, productInput.id)); - }); - }); + // Clean up base product + t.onTestFinished(async () => { + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + }); + }); - test("DELETE /v1/products/:productId/provider-products/:providerId/:providerProductKey - not found", async (t) => { - const h = await IntegrationHarness.init(t); - const productId = generateId("test"); - const providerId = "stripe"; - const nonExistentKey = `ppk_nonexistent_${generateId("test")}`; + test('DELETE /v1/products/:productId/provider-products/:providerId/:providerProductKey - not found', async (t) => { + const h = await IntegrationHarness.init(t); + const productId = generateId('test'); + const providerId = 'stripe'; + const nonExistentKey = `ppk_nonexistent_${generateId('test')}`; - const res = await h.delete({ - url: `/v1/products/${productId}/provider-products/${providerId}/${nonExistentKey}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.delete({ + url: `/v1/products/${productId}/provider-products/${providerId}/${nonExistentKey}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - // Assuming the service returns 404 when the provider product is not found - expect( - res.status, - `expected 404, received: ${JSON.stringify(res, null, 2)}` - ).toBe(404); - }); - } + // Assuming the service returns 404 when the provider product is not found + expect( + res.status, + `expected 404, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + }); + } ); diff --git a/apps/web/lib/api/v1/products_deleteProviderProduct.ts b/apps/web/lib/api/v1/products_deleteProviderProduct.ts index 35dcdae09..0dbd504e0 100644 --- a/apps/web/lib/api/v1/products_deleteProviderProduct.ts +++ b/apps/web/lib/api/v1/products_deleteProviderProduct.ts @@ -1,79 +1,79 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { z } from "zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { deleteProviderProductParamsSchema } from "./schema"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; -import { ProductService } from "@/lib/services/product.service"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { ProductService } from '@/lib/services/product.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { deleteProviderProductParamsSchema } from './schema'; const route = describeRoute({ - description: "Delete a provider product", - operationId: "deleteProviderProduct", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: resolver(z.object({ message: z.string() })), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Products"], + description: 'Delete a provider product', + operationId: 'deleteProviderProduct', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(z.object({ message: z.string() })) + } + } + }, + ...openApiErrorResponses + }, + tags: ['Products'] }); export type Route = typeof route; export const registerProductsDeleteProviderProduct = (app: App) => - app.delete( - "/v1/products/:productId/provider-products/:paymentProviderConfigurationId/:providerProductKey", - route, - zValidator("param", deleteProviderProductParamsSchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const productService = yield* ProductService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - yield* productService - .deletePaymentProviderProduct({ - productId: c.req.param("productId"), - paymentProviderConfigurationId: c.req.param( - "paymentProviderConfigurationId" - ), - providerProductKey: c.req.param("providerProductKey"), - }) - .pipe( - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }) - ), - }) - ); + app.delete( + '/v1/products/:productId/provider-products/:paymentProviderConfigurationId/:providerProductKey', + route, + zValidator('param', deleteProviderProductParamsSchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const productService = yield* ProductService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + yield* productService + .deletePaymentProviderProduct({ + productId: c.req.param('productId'), + paymentProviderConfigurationId: c.req.param( + 'paymentProviderConfigurationId' + ), + providerProductKey: c.req.param('providerProductKey') + }) + .pipe( + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ) + }) + ); - return c.json({ message: "Provider product deleted" }); - }) - ); - }) - ) - ); + return c.json({ message: 'Provider product deleted' }); + }) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/products_getProductById.test.ts b/apps/web/lib/api/v1/products_getProductById.test.ts index acdb534f6..5b9936827 100644 --- a/apps/web/lib/api/v1/products_getProductById.test.ts +++ b/apps/web/lib/api/v1/products_getProductById.test.ts @@ -1,75 +1,75 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { InsertProduct, products } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { productResponseSchema } from "./schema"; -import { Environment } from "@voidhash/lib/constants"; +import { type InsertProduct, products } from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { productResponseSchema } from './schema'; -describe.sequential("/v1/products/:productId", async () => { - test("GET /v1/products/:productId - success", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('/v1/products/:productId', () => { + test('GET /v1/products/:productId - success', async (t) => { + const h = await IntegrationHarness.init(t); - // Directly insert a product for testing - const productInput: Omit = { - id: generateId("test"), - name: "Get Product By ID Test", - environment: Environment.Production, - }; + // Directly insert a product for testing + const productInput: Omit = { + id: generateId('test'), + name: 'Get Product By ID Test', + environment: Environment.Production + }; - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); - const res = await h.get({ - url: `/v1/products/${productInput.id}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: `/v1/products/${productInput.id}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer; + const responseBody = res.body as z.infer; - expect(responseBody.productId).toBe(productInput.id); - expect(responseBody.name).toBe(productInput.name); + expect(responseBody.productId).toBe(productInput.id); + expect(responseBody.name).toBe(productInput.name); - // Clean up the created product - t.onTestFinished(async () => { - await h.db.primary - .delete(products) - .where(eq(products.id, productInput.id)); - }); - }); + // Clean up the created product + t.onTestFinished(async () => { + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + }); + }); - test("GET /v1/products/:productId - not found", async (t) => { - const h = await IntegrationHarness.init(t); - const nonExistentProductId = `non-existent-${generateId("test")}`; + test('GET /v1/products/:productId - not found', async (t) => { + const h = await IntegrationHarness.init(t); + const nonExistentProductId = `non-existent-${generateId('test')}`; - const res = await h.get({ - url: `/v1/products/${nonExistentProductId}`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: `/v1/products/${nonExistentProductId}`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 404, received: ${JSON.stringify(res, null, 2)}` - ).toBe(404); - expect(res.body).toEqual({ - error: { - code: "NOT_FOUND", - docs: expect.any(String), - message: "Product not found", - requestId: expect.any(String), - }, - }); - }); + expect( + res.status, + `expected 404, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + expect(res.body).toEqual({ + error: { + code: 'NOT_FOUND', + docs: expect.any(String), + message: 'Product not found', + requestId: expect.any(String) + } + }); + }); }); diff --git a/apps/web/lib/api/v1/products_getProductById.ts b/apps/web/lib/api/v1/products_getProductById.ts index d246bf1f6..fa877f94e 100644 --- a/apps/web/lib/api/v1/products_getProductById.ts +++ b/apps/web/lib/api/v1/products_getProductById.ts @@ -1,74 +1,74 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { getProductByIdParamsSchema, productResponseSchema } from "./schema"; -import { z } from "zod"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { ProductService } from "@/lib/services/product.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { ProductService } from '@/lib/services/product.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { getProductByIdParamsSchema, productResponseSchema } from './schema'; const route = describeRoute({ - description: "Get a product", - operationId: "getProductById", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(productResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Products"], + description: 'Get a product', + operationId: 'getProductById', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(productResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['Products'] }); export type Route = typeof route; export const registerProductsGetProductById = (app: App) => - app.get( - "/v1/products/:productId", - route, - zValidator("param", getProductByIdParamsSchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const productService = yield* ProductService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const product = yield* productService.getProductById( - c.req.param("productId") - ); + app.get( + '/v1/products/:productId', + route, + zValidator('param', getProductByIdParamsSchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const productService = yield* ProductService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const product = yield* productService.getProductById( + c.req.param('productId') + ); - if (!product) { - return yield* Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: "Product not found", - }) - ); - } + if (!product) { + return yield* Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: 'Product not found' + }) + ); + } - return c.json>({ - productId: product.id, - name: product.name, - }); - }) - ); - }) - ) - ); + return c.json>({ + productId: product.id, + name: product.name + }); + }) + ); + }) + ) + ); export type RouteResponse = z.infer; diff --git a/apps/web/lib/api/v1/products_getProviderProductsByProductId.test.ts b/apps/web/lib/api/v1/products_getProviderProductsByProductId.test.ts index fc6c3cf7e..627fe305d 100644 --- a/apps/web/lib/api/v1/products_getProviderProductsByProductId.test.ts +++ b/apps/web/lib/api/v1/products_getProviderProductsByProductId.test.ts @@ -1,138 +1,138 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; import { - InsertProduct, - paymentProviderConfigurationProducts, - InsertPaymentProviderConfigurationProduct, - products, -} from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { providerProductResponseSchema } from "./schema"; -import { Environment } from "@voidhash/lib/constants"; + type InsertPaymentProviderConfigurationProduct, + type InsertProduct, + paymentProviderConfigurationProducts, + products +} from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { providerProductResponseSchema } from './schema'; -describe.sequential("/v1/products/:productId/provider-products", async () => { - test("GET /v1/products/:productId/provider-products - success", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('/v1/products/:productId/provider-products', () => { + test('GET /v1/products/:productId/provider-products - success', async (t) => { + const h = await IntegrationHarness.init(t); - // Create a base product - const productInput: Omit = { - id: generateId("test"), - name: "Base Product for Get Provider List", - environment: Environment.Production, - }; - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); + // Create a base product + const productInput: Omit = { + id: generateId('test'), + name: 'Base Product for Get Provider List', + environment: Environment.Production + }; + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); - // Directly insert provider products - const providerConfig1: Omit< - InsertPaymentProviderConfigurationProduct, - "productId" - > = { - id: generateId("test"), - paymentProviderConfigurationId: - h.resources.paymentProviderConfiguration.id, - providerProductKey: `ppk_${generateId("test")}`, - configuration: { stripePriceId: `price_${generateId("test")}` }, // Simplified - isActive: true, - }; - await h.db.primary.insert(paymentProviderConfigurationProducts).values({ - ...providerConfig1, - productId: productInput.id, - }); + // Directly insert provider products + const providerConfig1: Omit< + InsertPaymentProviderConfigurationProduct, + 'productId' + > = { + id: generateId('test'), + paymentProviderConfigurationId: + h.resources.paymentProviderConfiguration.id, + providerProductKey: `ppk_${generateId('test')}`, + configuration: { stripePriceId: `price_${generateId('test')}` }, // Simplified + isActive: true + }; + await h.db.primary.insert(paymentProviderConfigurationProducts).values({ + ...providerConfig1, + productId: productInput.id + }); - const res = await h.get({ - url: `/v1/products/${productInput.id}/provider-products`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: `/v1/products/${productInput.id}/provider-products`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer< - typeof providerProductResponseSchema - >[]; - expect(responseBody).toHaveLength(1); - expect(responseBody[0]!.providerProductKey).toBe( - providerConfig1.providerProductKey - ); - expect(responseBody[0]!.providerConfiguration.configuration).toEqual( - providerConfig1.configuration - ); + const responseBody = res.body as z.infer< + typeof providerProductResponseSchema + >[]; + expect(responseBody).toHaveLength(1); + expect(responseBody[0]?.providerProductKey).toBe( + providerConfig1.providerProductKey + ); + expect(responseBody[0]?.providerConfiguration.configuration).toEqual( + providerConfig1.configuration + ); - // Clean up - t.onTestFinished(async () => { - await h.db.primary - .delete(paymentProviderConfigurationProducts) - .where( - eq( - paymentProviderConfigurationProducts.providerProductKey, - providerConfig1.providerProductKey - ) - ); - await h.db.primary - .delete(products) - .where(eq(products.id, productInput.id)); - }); - }); + // Clean up + t.onTestFinished(async () => { + await h.db.primary + .delete(paymentProviderConfigurationProducts) + .where( + eq( + paymentProviderConfigurationProducts.providerProductKey, + providerConfig1.providerProductKey + ) + ); + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + }); + }); - test("GET /v1/products/:productId/provider-products - empty list", async (t) => { - const h = await IntegrationHarness.init(t); + test('GET /v1/products/:productId/provider-products - empty list', async (t) => { + const h = await IntegrationHarness.init(t); - // Create a base product without provider products - const productInput: Omit = { - id: generateId("test"), - name: "Base Product Empty List", - environment: Environment.Production, - }; - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); + // Create a base product without provider products + const productInput: Omit = { + id: generateId('test'), + name: 'Base Product Empty List', + environment: Environment.Production + }; + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); - const res = await h.get({ - url: `/v1/products/${productInput.id}/provider-products`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: `/v1/products/${productInput.id}/provider-products`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - expect(res.body).toEqual([]); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + expect(res.body).toEqual([]); - // Clean up - t.onTestFinished(async () => { - await h.db.primary - .delete(products) - .where(eq(products.id, productInput.id)); - }); - }); + // Clean up + t.onTestFinished(async () => { + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + }); + }); - test("GET /v1/products/:productId/provider-products - product not found", async (t) => { - const h = await IntegrationHarness.init(t); - const nonExistentProductId = `non-existent-${generateId("test")}`; + test('GET /v1/products/:productId/provider-products - product not found', async (t) => { + const h = await IntegrationHarness.init(t); + const nonExistentProductId = `non-existent-${generateId('test')}`; - const res = await h.get({ - url: `/v1/products/${nonExistentProductId}/provider-products`, - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: `/v1/products/${nonExistentProductId}/provider-products`, + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - // The service might return an empty list even if the product doesn't exist. - expect( - res.status, - `expected 404, received: ${JSON.stringify(res, null, 2)}` - ).toBe(404); - }); + // The service might return an empty list even if the product doesn't exist. + expect( + res.status, + `expected 404, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + }); }); diff --git a/apps/web/lib/api/v1/products_getProviderProductsByProductId.ts b/apps/web/lib/api/v1/products_getProviderProductsByProductId.ts index 42194ac34..75ddccd49 100644 --- a/apps/web/lib/api/v1/products_getProviderProductsByProductId.ts +++ b/apps/web/lib/api/v1/products_getProviderProductsByProductId.ts @@ -1,75 +1,75 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; +import { createEffectHandler } from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { ProductService } from '@/lib/services/product.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; import { - getProviderProductsParamsSchema, - providerProductResponseSchema, -} from "./schema"; -import { z } from "zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; -import { createEffectHandler } from "@/lib/effect/runtimes/hono"; -import { ProductService } from "@/lib/services/product.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + getProviderProductsParamsSchema, + providerProductResponseSchema +} from './schema'; const route = describeRoute({ - description: "Get all provider products for a product", - operationId: "getProviderProductsByProductId", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: resolver(z.array(providerProductResponseSchema)), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Products"], + description: 'Get all provider products for a product', + operationId: 'getProviderProductsByProductId', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(z.array(providerProductResponseSchema)) + } + } + }, + ...openApiErrorResponses + }, + tags: ['Products'] }); export type Route = typeof route; export const registerProductsGetProviderProductsByProductId = (app: App) => - app.get( - "/v1/products/:productId/provider-products", - route, - zValidator("param", getProviderProductsParamsSchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const productService = yield* ProductService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const providerProducts = - yield* productService.getProviderProductsByProductId( - c.req.param("productId"), - ); + app.get( + '/v1/products/:productId/provider-products', + route, + zValidator('param', getProviderProductsParamsSchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const productService = yield* ProductService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const providerProducts = + yield* productService.getProviderProductsByProductId( + c.req.param('productId') + ); - return c.json[]>( - // @ts-expect-error - TODO: fix this - providerProducts.map((providerProduct) => ({ - providerProductKey: providerProduct.providerProductKey, - providerConfiguration: { - paymentProviderConfigurationId: - providerProduct.paymentProviderConfigurationId, - configuration: providerProduct.configuration, - }, - })), - ); - }), - ); - }), - ), - ); + return c.json[]>( + // @ts-expect-error - TODO: fix this + providerProducts.map((providerProduct) => ({ + providerProductKey: providerProduct.providerProductKey, + providerConfiguration: { + paymentProviderConfigurationId: + providerProduct.paymentProviderConfigurationId, + configuration: providerProduct.configuration + } + })) + ); + }) + ); + }) + ) + ); export type RouteResponse = z.infer[]; diff --git a/apps/web/lib/api/v1/products_listProducts.test.ts b/apps/web/lib/api/v1/products_listProducts.test.ts index fbde096a2..1a49d4856 100644 --- a/apps/web/lib/api/v1/products_listProducts.test.ts +++ b/apps/web/lib/api/v1/products_listProducts.test.ts @@ -1,69 +1,69 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { InsertProduct, products } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { productResponseSchema } from "./schema"; -import { Environment } from "@voidhash/lib/constants"; +import { type InsertProduct, products } from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { productResponseSchema } from './schema'; -const productInput: Omit = { - id: generateId("test"), - name: "Test Product for List", - environment: Environment.Production, +const productInput: Omit = { + id: generateId('test'), + name: 'Test Product for List', + environment: Environment.Production }; const expectedProduct: z.infer = { - productId: productInput.id, - name: productInput.name, + productId: productInput.id, + name: productInput.name }; -describe.sequential("/v1/products/**", async () => { - test("GET /v1/products - empty list", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('/v1/products/**', () => { + test('GET /v1/products - empty list', async (t) => { + const h = await IntegrationHarness.init(t); - const res = await h.get({ - url: "/v1/products", - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: '/v1/products', + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - expect(res.body).toEqual([]); - }); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + expect(res.body).toEqual([]); + }); - test("GET /v1/products - products", async (t) => { - const h = await IntegrationHarness.init(t); + test('GET /v1/products - products', async (t) => { + const h = await IntegrationHarness.init(t); - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); - const res = await h.get({ - url: "/v1/products", - headers: { - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - }); + const res = await h.get({ + url: '/v1/products', + headers: { + 'x-secret-key': h.resources.secretKey.unhashedKey + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer[]; - expect(responseBody).toStrictEqual([expectedProduct]); + const responseBody = res.body as z.infer[]; + expect(responseBody).toStrictEqual([expectedProduct]); - // Delete the product - t.onTestFinished(async () => { - await h.db.primary - .delete(products) - .where(eq(products.id, productInput.id)); - }); - }); + // Delete the product + t.onTestFinished(async () => { + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + }); + }); }); diff --git a/apps/web/lib/api/v1/products_listProducts.ts b/apps/web/lib/api/v1/products_listProducts.ts index bf1d4d047..ed76caa59 100644 --- a/apps/web/lib/api/v1/products_listProducts.ts +++ b/apps/web/lib/api/v1/products_listProducts.ts @@ -1,68 +1,68 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { productResponseSchema } from "./schema"; -import { z } from "zod"; -import { App } from "../hono/app"; -import { createEffectHandler } from "@/lib/effect/runtimes/hono"; -import { ProductService } from "@/lib/services/product.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; +import { createEffectHandler } from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { ProductService } from '@/lib/services/product.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { productResponseSchema } from './schema'; const route = describeRoute({ - description: "List products", - operationId: "listProducts", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: resolver(z.array(productResponseSchema)), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Products"], + description: 'List products', + operationId: 'listProducts', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(z.array(productResponseSchema)) + } + } + }, + ...openApiErrorResponses + }, + tags: ['Products'] }); export type Route = typeof route; export const registerProductsListProducts = (app: App) => - app.get("/v1/products", route, async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const productService = yield* ProductService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); - const projectId = yield* authService.getAuthorizedProjectId(); - const products = yield* Environment.provide(environment)( - productService.getProducts(projectId) - ); - return c.json[]>( - products.map((product) => ({ - productId: product.id, - name: product.name, - })) - ); - }) - ); - }) - ) - ); + app.get('/v1/products', route, async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const productService = yield* ProductService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + const projectId = yield* authService.getAuthorizedProjectId(); + const products = yield* Environment.provide(environment)( + productService.getProducts(projectId) + ); + return c.json[]>( + products.map((product) => ({ + productId: product.id, + name: product.name + })) + ); + }) + ); + }) + ) + ); export type RouteResponse = z.infer[]; diff --git a/apps/web/lib/api/v1/products_updateProduct.test.ts b/apps/web/lib/api/v1/products_updateProduct.test.ts index 664b09d68..99c729dda 100644 --- a/apps/web/lib/api/v1/products_updateProduct.test.ts +++ b/apps/web/lib/api/v1/products_updateProduct.test.ts @@ -1,86 +1,86 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { InsertProduct, products } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { productResponseSchema, updateProductBodySchema } from "./schema"; -import { Environment } from "@voidhash/lib/constants"; +import { type InsertProduct, products } from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { productResponseSchema, updateProductBodySchema } from './schema'; -describe.sequential("/v1/products/:productId", async () => { - test("PUT /v1/products/:productId - success", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('/v1/products/:productId', () => { + test('PUT /v1/products/:productId - success', async (t) => { + const h = await IntegrationHarness.init(t); - // Directly insert a product for testing - const productInput: Omit = { - id: generateId("test"), - name: "Original Product Name", - environment: Environment.Production, - }; + // Directly insert a product for testing + const productInput: Omit = { + id: generateId('test'), + name: 'Original Product Name', + environment: Environment.Production + }; - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); - const updateInput: z.infer = { - name: "Updated Product Name", - }; + const updateInput: z.infer = { + name: 'Updated Product Name' + }; - const res = await h.put({ - url: `/v1/products/${productInput.id}`, - headers: { - "Content-Type": "application/json", - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - body: updateInput, - }); + const res = await h.put({ + url: `/v1/products/${productInput.id}`, + headers: { + 'Content-Type': 'application/json', + 'x-secret-key': h.resources.secretKey.unhashedKey + }, + body: updateInput + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer; + const responseBody = res.body as z.infer; - expect(responseBody.productId).toBe(productInput.id); - expect(responseBody.name).toBe(updateInput.name); + expect(responseBody.productId).toBe(productInput.id); + expect(responseBody.name).toBe(updateInput.name); - // Verify the change in the database - const dbProduct = await h.db.primary.query.products.findFirst({ - where: eq(products.id, productInput.id), - }); - expect(dbProduct?.name).toBe(updateInput.name); + // Verify the change in the database + const dbProduct = await h.db.primary.query.products.findFirst({ + where: eq(products.id, productInput.id) + }); + expect(dbProduct?.name).toBe(updateInput.name); - // Clean up the created product - t.onTestFinished(async () => { - await h.db.primary - .delete(products) - .where(eq(products.id, productInput.id)); - }); - }); + // Clean up the created product + t.onTestFinished(async () => { + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + }); + }); - test("PUT /v1/products/:productId - not found", async (t) => { - const h = await IntegrationHarness.init(t); - const nonExistentProductId = `non-existent-${generateId("test")}`; - const updateInput: z.infer = { - name: "Updated Product Name", - }; + test('PUT /v1/products/:productId - not found', async (t) => { + const h = await IntegrationHarness.init(t); + const nonExistentProductId = `non-existent-${generateId('test')}`; + const updateInput: z.infer = { + name: 'Updated Product Name' + }; - const res = await h.put({ - url: `/v1/products/${nonExistentProductId}`, - headers: { - "Content-Type": "application/json", - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - body: updateInput, - }); + const res = await h.put({ + url: `/v1/products/${nonExistentProductId}`, + headers: { + 'Content-Type': 'application/json', + 'x-secret-key': h.resources.secretKey.unhashedKey + }, + body: updateInput + }); - // Assuming updateProduct service throws an error leading to a 500 or similar - // or potentially a 404 if handled gracefully. Let's expect 404 for now. - expect( - res.status, - `expected 404/500, received: ${JSON.stringify(res, null, 2)}` - ).toBe(404); // Adjust if the actual service function returns a different error - }); + // Assuming updateProduct service throws an error leading to a 500 or similar + // or potentially a 404 if handled gracefully. Let's expect 404 for now. + expect( + res.status, + `expected 404/500, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); // Adjust if the actual service function returns a different error + }); }); diff --git a/apps/web/lib/api/v1/products_updateProduct.ts b/apps/web/lib/api/v1/products_updateProduct.ts index 2c80523d7..6d5b1aeaf 100644 --- a/apps/web/lib/api/v1/products_updateProduct.ts +++ b/apps/web/lib/api/v1/products_updateProduct.ts @@ -1,88 +1,88 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - productResponseSchema, - updateProductBodySchema, - updateProductParamsSchema, -} from "./schema"; -import { z } from "zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { ProductService } from '@/lib/services/product.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { ProductService } from "@/lib/services/product.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + productResponseSchema, + updateProductBodySchema, + updateProductParamsSchema +} from './schema'; const route = describeRoute({ - description: "Update a product", - operationId: "updateProduct", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(productResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Products"], + description: 'Update a product', + operationId: 'updateProduct', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(productResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['Products'] }); export type Route = typeof route; export const registerProductsUpdateProduct = (app: App) => - app.put( - "/v1/products/:productId", - route, - zValidator("param", updateProductParamsSchema), - zValidator("json", updateProductBodySchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const productService = yield* ProductService; - const authSession = yield* authService.authenticateWithSecretKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const productId = c.req.param("productId"); - const name = c.req.valid("json").name; - yield* productService - .updateProduct({ - productId: productId, - name, - }) - .pipe( - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }) - ), - }) - ); - const product = yield* productService.getProductById(productId); + app.put( + '/v1/products/:productId', + route, + zValidator('param', updateProductParamsSchema), + zValidator('json', updateProductBodySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const productService = yield* ProductService; + const authSession = yield* authService.authenticateWithSecretKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const productId = c.req.param('productId'); + const name = c.req.valid('json').name; + yield* productService + .updateProduct({ + productId, + name + }) + .pipe( + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ) + }) + ); + const product = yield* productService.getProductById(productId); - return c.json>({ - productId: product.id, - name: product.name, - }); - }) - ); - }) - ) - ); + return c.json>({ + productId: product.id, + name: product.name + }); + }) + ); + }) + ) + ); export type RouteResponse = z.infer; export type RouteRequest = z.infer; diff --git a/apps/web/lib/api/v1/products_updateProviderProduct.test.ts b/apps/web/lib/api/v1/products_updateProviderProduct.test.ts index 9a01e3ba7..870705259 100644 --- a/apps/web/lib/api/v1/products_updateProviderProduct.test.ts +++ b/apps/web/lib/api/v1/products_updateProviderProduct.test.ts @@ -1,166 +1,169 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; import { - InsertProduct, - paymentProviderConfigurationProducts, - InsertPaymentProviderConfigurationProduct, - products, -} from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { stripe } from "@/lib/payment-providers/stripe/stripe"; -import { RouteResponse, RouteRequest } from "./products_updateProviderProduct"; -import { createPaymentProviderKey } from "@/lib/core/products/lib"; -import { Environment } from "@voidhash/lib/constants"; + type InsertPaymentProviderConfigurationProduct, + type InsertProduct, + paymentProviderConfigurationProducts, + products +} from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { createPaymentProviderKey } from '@/lib/core/products/lib'; +import { generateId } from '@/lib/id/generate'; +import type { stripe } from '@/lib/payment-providers/stripe/stripe'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { + RouteRequest, + RouteResponse +} from './products_updateProviderProduct'; describe.sequential( - "/v1/products/:productId/provider-products/:paymentProviderConfigurationProductId", - async () => { - test("PUT /v1/products/:productId/provider-products/:paymentProviderConfigurationProductId - success", async (t) => { - const h = await IntegrationHarness.init(t); - - // Create a base product - const productInput: Omit = { - id: generateId("test"), - name: "Base Product for Update Provider", - environment: Environment.Production, - }; - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); - - const providerProductKey = createPaymentProviderKey("stripe", { - productId: `prod_123`, - priceId: `price_123`, - }); - - if (providerProductKey.isErr()) { - throw new Error("Failed to create provider product key"); - } - - // Directly insert an initial provider product - const initialProviderConfig: InsertPaymentProviderConfigurationProduct = { - id: generateId("test"), - productId: productInput.id, - paymentProviderConfigurationId: - h.resources.paymentProviderConfiguration.id, - providerProductKey: providerProductKey.value, - configuration: { - productId: `prod_123`, - priceId: `price_123`, - } satisfies z.infer< - ReturnType - >, - isActive: true, - }; - await h.db.primary - .insert(paymentProviderConfigurationProducts) - .values(initialProviderConfig); - - // Define the update payload - const updatePayload: RouteRequest = { - paymentProviderConfigurationId: - h.resources.paymentProviderConfiguration.id, - providerId: "stripe", - configuration: { - productId: `prod_123`, - // @ts-expect-error - TODO: fix this - priceId: `price_123`, - } satisfies z.infer< - ReturnType - >, - }; - - const res = await h.put({ - url: `/v1/products/${productInput.id}/provider-products/${initialProviderConfig.id}`, - headers: { - "Content-Type": "application/json", - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - body: updatePayload, - }); - - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}`, - ).toBe(200); - - const responseBody = res.body; - // Check response body - expect(responseBody.providerProductKey).toBe( - initialProviderConfig.providerProductKey, - ); - - expect(responseBody.providerConfiguration).toMatchObject({ - configuration: updatePayload.configuration, - paymentProviderConfigurationId: - h.resources.paymentProviderConfiguration.id, - }); - - // Verify in DB - const dbProviderProduct = - await h.db.primary.query.paymentProviderConfigurationProducts.findFirst( - { - where: eq( - paymentProviderConfigurationProducts.id, - initialProviderConfig.id, - ), - }, - ); - - expect(dbProviderProduct?.configuration).toMatchObject( - updatePayload.configuration, // The DB stores only the inner config - ); - - // Clean up - t.onTestFinished(async () => { - await h.db.primary - .delete(paymentProviderConfigurationProducts) - .where( - eq( - paymentProviderConfigurationProducts.id, - initialProviderConfig.id, - ), - ); - await h.db.primary - .delete(products) - .where(eq(products.id, productInput.id)); - }); - }); - - test("PUT /v1/products/:productId/provider-products/:paymentProviderConfigurationProductId - not found", async (t) => { - const h = await IntegrationHarness.init(t); - const productId = generateId("test"); - const nonExistentKey = `ppk_nonexistent_${generateId("test")}`; - const updatePayload: RouteRequest = { - paymentProviderConfigurationId: - h.resources.paymentProviderConfiguration.id, - providerId: "stripe", - configuration: { - productId: `prod_123`, - // @ts-expect-error - TODO: fix this - priceId: `price_update_fail`, - } satisfies z.infer< - ReturnType - >, - }; - - const res = await h.put({ - url: `/v1/products/${productId}/provider-products/${nonExistentKey}`, - headers: { - "Content-Type": "application/json", - "x-secret-key": h.resources.secretKey.unhashedKey, - }, - body: updatePayload, - }); - - // Assuming the service returns 404 when the provider product is not found - expect( - res.status, - `expected 404, received: ${JSON.stringify(res, null, 2)}`, - ).toBe(404); - }); - }, + '/v1/products/:productId/provider-products/:paymentProviderConfigurationProductId', + () => { + test('PUT /v1/products/:productId/provider-products/:paymentProviderConfigurationProductId - success', async (t) => { + const h = await IntegrationHarness.init(t); + + // Create a base product + const productInput: Omit = { + id: generateId('test'), + name: 'Base Product for Update Provider', + environment: Environment.Production + }; + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); + + const providerProductKey = createPaymentProviderKey('stripe', { + productId: 'prod_123', + priceId: 'price_123' + }); + + if (providerProductKey.isErr()) { + throw new Error('Failed to create provider product key'); + } + + // Directly insert an initial provider product + const initialProviderConfig: InsertPaymentProviderConfigurationProduct = { + id: generateId('test'), + productId: productInput.id, + paymentProviderConfigurationId: + h.resources.paymentProviderConfiguration.id, + providerProductKey: providerProductKey.value, + configuration: { + productId: 'prod_123', + priceId: 'price_123' + } satisfies z.infer< + ReturnType + >, + isActive: true + }; + await h.db.primary + .insert(paymentProviderConfigurationProducts) + .values(initialProviderConfig); + + // Define the update payload + const updatePayload: RouteRequest = { + paymentProviderConfigurationId: + h.resources.paymentProviderConfiguration.id, + providerId: 'stripe', + configuration: { + productId: 'prod_123', + // @ts-expect-error - TODO: fix this + priceId: 'price_123' + } satisfies z.infer< + ReturnType + > + }; + + const res = await h.put({ + url: `/v1/products/${productInput.id}/provider-products/${initialProviderConfig.id}`, + headers: { + 'Content-Type': 'application/json', + 'x-secret-key': h.resources.secretKey.unhashedKey + }, + body: updatePayload + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + + const responseBody = res.body; + // Check response body + expect(responseBody.providerProductKey).toBe( + initialProviderConfig.providerProductKey + ); + + expect(responseBody.providerConfiguration).toMatchObject({ + configuration: updatePayload.configuration, + paymentProviderConfigurationId: + h.resources.paymentProviderConfiguration.id + }); + + // Verify in DB + const dbProviderProduct = + await h.db.primary.query.paymentProviderConfigurationProducts.findFirst( + { + where: eq( + paymentProviderConfigurationProducts.id, + initialProviderConfig.id + ) + } + ); + + expect(dbProviderProduct?.configuration).toMatchObject( + updatePayload.configuration // The DB stores only the inner config + ); + + // Clean up + t.onTestFinished(async () => { + await h.db.primary + .delete(paymentProviderConfigurationProducts) + .where( + eq( + paymentProviderConfigurationProducts.id, + initialProviderConfig.id + ) + ); + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + }); + }); + + test('PUT /v1/products/:productId/provider-products/:paymentProviderConfigurationProductId - not found', async (t) => { + const h = await IntegrationHarness.init(t); + const productId = generateId('test'); + const nonExistentKey = `ppk_nonexistent_${generateId('test')}`; + const updatePayload: RouteRequest = { + paymentProviderConfigurationId: + h.resources.paymentProviderConfiguration.id, + providerId: 'stripe', + configuration: { + productId: 'prod_123', + // @ts-expect-error - TODO: fix this + priceId: 'price_update_fail' + } satisfies z.infer< + ReturnType + > + }; + + const res = await h.put({ + url: `/v1/products/${productId}/provider-products/${nonExistentKey}`, + headers: { + 'Content-Type': 'application/json', + 'x-secret-key': h.resources.secretKey.unhashedKey + }, + body: updatePayload + }); + + // Assuming the service returns 404 when the provider product is not found + expect( + res.status, + `expected 404, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + }); + } ); diff --git a/apps/web/lib/api/v1/products_updateProviderProduct.ts b/apps/web/lib/api/v1/products_updateProviderProduct.ts index 40d30ca4f..4c5888caf 100644 --- a/apps/web/lib/api/v1/products_updateProviderProduct.ts +++ b/apps/web/lib/api/v1/products_updateProviderProduct.ts @@ -1,148 +1,145 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - providerProductResponseSchema, - updateProviderProductBodySchema, - updateProviderProductParamsSchema, -} from "./schema"; -import { z } from "zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { App } from "../hono/app"; -import { zValidator } from "@hono/zod-validator"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { ProductService } from "@/lib/services/product.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { ProductService } from '@/lib/services/product.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + providerProductResponseSchema, + updateProviderProductBodySchema, + updateProviderProductParamsSchema +} from './schema'; const route = describeRoute({ - description: "Update a provider product", - operationId: "updateProviderProduct", - security: [ - { - secretKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: resolver(providerProductResponseSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Products"], + description: 'Update a provider product', + operationId: 'updateProviderProduct', + security: [ + { + secretKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(providerProductResponseSchema) + } + } + }, + ...openApiErrorResponses + }, + tags: ['Products'] }); export type Route = typeof route; export const registerProductsUpdateProviderProduct = (app: App) => - app.put( - "/v1/products/:productId/provider-products/:paymentProviderConfigurationProductId", - route, - zValidator("param", updateProviderProductParamsSchema), - zValidator("json", updateProviderProductBodySchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const productService = yield* ProductService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSecretKey(); + app.put( + '/v1/products/:productId/provider-products/:paymentProviderConfigurationProductId', + route, + zValidator('param', updateProviderProductParamsSchema), + zValidator('json', updateProviderProductBodySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const productService = yield* ProductService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSecretKey(); - const paymentProviderConfigurationProductId = c.req.param( - "paymentProviderConfigurationProductId", - ); - const configuration = c.req.valid("json"); + const paymentProviderConfigurationProductId = c.req.param( + 'paymentProviderConfigurationProductId' + ); + const configuration = c.req.valid('json'); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); - return yield* Environment.provide(environment)( - Effect.gen(function* () { - yield* productService.updatePaymentProviderProduct({ - paymentProviderConfigurationProductId: - paymentProviderConfigurationProductId, - configuration: configuration.configuration, - }); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + yield* productService.updatePaymentProviderProduct({ + paymentProviderConfigurationProductId, + configuration: configuration.configuration + }); - // Get the updated provider product to return full details - const providerProduct = - yield* productService.getProviderProductById( - paymentProviderConfigurationProductId, - ); + // Get the updated provider product to return full details + const providerProduct = + yield* productService.getProviderProductById( + paymentProviderConfigurationProductId + ); - console.log("updatePaymentProviderProduct!"); - - return c.json>({ - providerProductKey: providerProduct.providerProductKey, - providerConfiguration: { - // @ts-expect-error - TODO: fix this - configuration: providerProduct.configuration, - paymentProviderConfigurationId: - providerProduct.paymentProviderConfigurationId, - }, - }); - }), - ); - }).pipe( - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }), - ), - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - originalError: error, - }), - ), - PaymentProviderNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - originalError: error, - }), - ), - ProviderProductNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }), - ), - InvalidConfiguration: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - originalError: error, - }), - ), - }), - ), - ); - }), - ), - ); + return c.json>({ + providerProductKey: providerProduct.providerProductKey, + providerConfiguration: { + // @ts-expect-error - TODO: fix this + configuration: providerProduct.configuration, + paymentProviderConfigurationId: + providerProduct.paymentProviderConfigurationId + } + }); + }) + ); + }).pipe( + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ), + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'BAD_REQUEST', + message: error.message, + originalError: error + }) + ), + PaymentProviderNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'BAD_REQUEST', + message: error.message, + originalError: error + }) + ), + ProviderProductNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ), + InvalidConfiguration: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'BAD_REQUEST', + message: error.message, + originalError: error + }) + ) + }) + ) + ); + }) + ) + ); export type RouteResponse = z.infer; export type RouteRequest = z.infer; diff --git a/apps/web/lib/api/v1/schema.ts b/apps/web/lib/api/v1/schema.ts index bb3c573e9..77874c3c1 100644 --- a/apps/web/lib/api/v1/schema.ts +++ b/apps/web/lib/api/v1/schema.ts @@ -1,236 +1,293 @@ -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { z } from "zod"; +import { z } from 'zod'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; // Customer export const createCustomerBodySchema = z - .object({ - appUserId: z.string(), - name: z.string().optional(), - email: z.string().email().optional(), - }) - .meta({ - ref: "CreateCustomerBody", - }); + .object({ + appUserId: z.string(), + name: z.string().optional(), + email: z.string().email().optional() + }) + .meta({ + ref: 'CreateCustomerBody' + }); export const customerResponseSchema = z - .object({ - customerId: z.string(), - name: z.string().nullable(), - email: z.string().nullable(), - appUserId: z.string().nullable(), - // origin: z.enum(["dashboard", "ios", "android", "stripe", "api"]), - }) - .meta({ - ref: "Customer", - }); + .object({ + customerId: z.string(), + name: z.string().nullable(), + email: z.string().nullable(), + appUserId: z.string().nullable() + // origin: z.enum(["dashboard", "ios", "android", "stripe", "api"]), + }) + .meta({ + ref: 'Customer' + }); // Product export const createProductBodySchema = z - .object({ - name: z.string(), - }) - .meta({ - ref: "CreateProductBody", - }); + .object({ + name: z.string() + }) + .meta({ + ref: 'CreateProductBody' + }); export const productResponseSchema = z - .object({ - productId: z.string(), - name: z.string(), - }) - .meta({ - ref: "Product", - }); + .object({ + productId: z.string(), + name: z.string() + }) + .meta({ + ref: 'Product' + }); export const getProductByIdParamsSchema = z.object({ - productId: z.string(), + productId: z.string() }); export const updateProductBodySchema = z - .object({ - name: z.string(), - }) - .meta({ - ref: "UpdateProductBody", - }); + .object({ + name: z.string() + }) + .meta({ + ref: 'UpdateProductBody' + }); export const updateProductParamsSchema = z.object({ - productId: z.string(), + productId: z.string() }); export const deleteProductParamsSchema = z.object({ - productId: z.string(), + productId: z.string() }); const paymentProviderConfigurationProductSchema = z - .union([ - ...paymentProviders.map((p) => - z.object({ - providerId: z.literal(p.getId()), - paymentProviderConfigurationId: z.string(), - configuration: p.getProductConfigurationSchema(), - }), - ), - ]) - .meta({ - ref: "PaymentProviderConfigurationProduct", - }); + .union([ + ...paymentProviders.map((p) => + z.object({ + providerId: z.literal(p.getId()), + paymentProviderConfigurationId: z.string(), + configuration: p.getProductConfigurationSchema() + }) + ) + ]) + .meta({ + ref: 'PaymentProviderConfigurationProduct' + }); export const attachProviderProductParamsSchema = z.object({ - productId: z.string(), + productId: z.string() }); export const attachProviderProductBodySchema = - paymentProviderConfigurationProductSchema.meta({ - ref: "AttachProviderProductBody", - }); + paymentProviderConfigurationProductSchema.meta({ + ref: 'AttachProviderProductBody' + }); export const providerProductResponseSchema = z - .object({ - providerProductKey: z.string(), - providerConfiguration: paymentProviderConfigurationProductSchema, - }) - .meta({ - ref: "ProviderProduct", - }); + .object({ + providerProductKey: z.string(), + providerConfiguration: paymentProviderConfigurationProductSchema + }) + .meta({ + ref: 'ProviderProduct' + }); export const getProviderProductsParamsSchema = z.object({ - productId: z.string(), + productId: z.string() }); export const updateProviderProductParamsSchema = z.object({ - paymentProviderConfigurationProductId: z.string(), + paymentProviderConfigurationProductId: z.string() }); export const updateProviderProductBodySchema = - paymentProviderConfigurationProductSchema.meta({ - ref: "UpdateProviderProductBody", - }); + paymentProviderConfigurationProductSchema.meta({ + ref: 'UpdateProviderProductBody' + }); export const deleteProviderProductParamsSchema = z.object({ - productId: z.string(), - paymentProviderConfigurationId: z.string(), - providerProductKey: z.string(), + productId: z.string(), + paymentProviderConfigurationId: z.string(), + providerProductKey: z.string() }); // Paywall export const createPaywallBodySchema = z - .object({ - name: z.string(), - }) - .meta({ - ref: "CreatePaywallBody", - }); + .object({ + name: z.string() + }) + .meta({ + ref: 'CreatePaywallBody' + }); export const paywallResponseSchema = z - .object({ - paywallId: z.string(), - name: z.string(), - }) - .meta({ - ref: "Paywall", - }); + .object({ + paywallId: z.string(), + name: z.string() + }) + .meta({ + ref: 'Paywall' + }); export const getPaywallByIdParamsSchema = z.object({ - paywallId: z.string(), + paywallId: z.string() }); export const deletePaywallParamsSchema = z.object({ - paywallId: z.string(), + paywallId: z.string() }); // Paywall Product export const attachProductToPaywallParamsSchema = z.object({ - paywallId: z.string(), + paywallId: z.string() }); export const attachProductToPaywallBodySchema = z - .object({ - productId: z.string(), - }) - .meta({ - ref: "AttachProductToPaywallBody", - }); + .object({ + productId: z.string() + }) + .meta({ + ref: 'AttachProductToPaywallBody' + }); export const paywallProductResponseSchema = z - .object({ - paywallId: z.string(), - productId: z.string(), - productName: z.string().nullable(), - }) - .meta({ - ref: "PaywallProduct", - }); + .object({ + paywallId: z.string(), + productId: z.string(), + productName: z.string().nullable() + }) + .meta({ + ref: 'PaywallProduct' + }); export const getPaywallProductsParamsSchema = z.object({ - paywallId: z.string(), + paywallId: z.string() }); export const deletePaywallProductParamsSchema = z.object({ - paywallId: z.string(), - productId: z.string(), + paywallId: z.string(), + productId: z.string() }); // SDK export const sdkGetPaywallByLocationParamsSchema = z.object({ - locationSlug: z.string(), + locationSlug: z.string() +}); + +export const sdkGetPaywallByLocationQuerySchema = z.object({ + nativePaymentProviderId: z.string().optional() }); export const sdkPaywallResponseSchema = z - .object({ - paywallId: z.string(), - paywallProducts: z.array( - z.object({ - paywallProductId: z.string(), - productId: z.string(), - displayName: z.string(), - price: z.number().nullable(), - nativePurchaseAvailable: z.boolean(), - webCheckoutAvailable: z.boolean(), - webCheckoutPaymentProviderConfigurationProductId: z.string().nullable(), - }), - ), - }) - .meta({ - ref: "SdkPaywall", - }); + .object({ + paywallId: z.string(), + paywallProducts: z.array( + z.object({ + paywallProductId: z.string(), + productId: z.string(), + displayName: z.string(), + price: z.number().nullable(), + nativePurchaseAvailable: z.boolean(), + webCheckoutAvailable: z.boolean(), + webCheckoutPaymentProviderConfigurationProductId: z.string().nullable() + }) + ) + }) + .meta({ + ref: 'SdkPaywall' + }); export const sdkCreateCheckoutBodySchema = z - .object({ - paymentProviderConfigurationProductId: z.string(), - successCallbackUrl: z.string().min(1).includes("://"), - errorCallbackUrl: z.string().min(1).includes("://"), - }) - .meta({ - ref: "SdkCreateCheckoutBody", - }); + .object({ + paymentProviderConfigurationProductId: z.string(), + successCallbackUrl: z.string().min(1).includes('://'), + errorCallbackUrl: z.string().min(1).includes('://') + }) + .meta({ + ref: 'SdkCreateCheckoutBody' + }); export const sdkCheckoutResponseSchema = z - .object({ - checkoutSessionId: z.string(), - checkoutUrl: z.string(), - }) - .meta({ - ref: "SdkCheckout", - }); + .object({ + checkoutSessionId: z.string(), + checkoutUrl: z.string() + }) + .meta({ + ref: 'SdkCheckout' + }); export const sdkCustomerResponseSchema = z - .object({ - customerId: z.string(), - name: z.string().nullable(), - email: z.string().nullable(), - appUserId: z.string().nullable(), - }) - .meta({ - ref: "SdkCustomer", - }); + .object({ + customerId: z.string(), + name: z.string().nullable(), + email: z.string().nullable(), + appUserId: z.string().nullable() + }) + .meta({ + ref: 'SdkCustomer' + }); export const sdkIdentifyCustomerBodySchema = z - .object({ - appUserId: z.string(), - name: z.string().optional(), - email: z.string().email().optional(), - }) - .meta({ - ref: "SdkIdentifyCustomerBody", - }); + .object({ + appUserId: z.string(), + name: z.string().optional(), + email: z.string().email().optional() + }) + .meta({ + ref: 'SdkIdentifyCustomerBody' + }); + +export const sdkGetConfigurationResponseSchema = z.object({ + paywalls: z.array( + z.object({ + paywallId: z.string(), + paywallProducts: z.array( + z.object({ + paywallProductId: z.string(), + productId: z.string(), + displayName: z.string(), + nativePaymentProviderConfigurationProductId: z.string().nullable(), + defaultWebCheckoutPaymentProviderConfigurationProductId: z + .string() + .nullable(), + paymentProviderConfigurationProducts: z.array( + z.object({ + paymentProviderConfigurationProductId: z.string(), + paymentProviderConfigurationId: z.string(), + configuration: z.record(z.string(), z.any()) + }) + ) + }) + ) + }) + ), + paywallLocations: z.array( + z.object({ + paywallLocationId: z.string(), + slug: z.string() + }) + ), + placements: z.array( + z.object({ + paywallId: z.string(), + paywallLocationId: z.string() + }) + ), + paymentProviderConfigurations: z.array( + z.object({ + paymentProviderConfigurationId: z.string(), + providerId: z.string() + }) + ) +}); + +export const sdkSyncCustomerAttributesBodySchema = z + .object({ + name: z.string().optional(), + email: z.string().optional() + }) + .meta({ + ref: 'SdkSyncCustomerAttributesBody' + }); diff --git a/apps/web/lib/api/v1/sdk_createCheckout.ts b/apps/web/lib/api/v1/sdk_createCheckout.ts index 4a78aa46b..b19c317ae 100644 --- a/apps/web/lib/api/v1/sdk_createCheckout.ts +++ b/apps/web/lib/api/v1/sdk_createCheckout.ts @@ -1,105 +1,105 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - sdkCheckoutResponseSchema, - sdkCreateCheckoutBodySchema, -} from "./schema"; -import { App } from "../hono/app"; -import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { SdkService } from "@/lib/services/sdk.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { SdkService } from '@/lib/services/sdk.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + sdkCheckoutResponseSchema, + sdkCreateCheckoutBodySchema +} from './schema'; const route = describeRoute({ - description: "Creates a new checkout session", - operationId: "sdkCreateCheckout", - security: [ - { - publishableKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(sdkCheckoutResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["SDK"], + description: 'Creates a new checkout session', + operationId: 'sdkCreateCheckout', + security: [ + { + publishableKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(sdkCheckoutResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['SDK'] }); export type Route = typeof route; export const registerSdkCreateCheckout = (app: App) => - app.post( - "/v1/sdk/create-checkout", - route, - zValidator("json", sdkCreateCheckoutBodySchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const sdkService = yield* SdkService; - const environmentService = yield* EnvironmentService; - const authSession = - yield* authService.authenticateWithPublishableKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); + app.post( + '/v1/sdk/create-checkout', + route, + zValidator('json', sdkCreateCheckoutBodySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const sdkService = yield* SdkService; + const environmentService = yield* EnvironmentService; + const authSession = + yield* authService.authenticateWithPublishableKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); - const checkout = yield* Environment.provide(environment)( - sdkService.createCheckout({ - paymentProviderConfigurationProductId: - c.req.valid("json").paymentProviderConfigurationProductId, - successCallbackUrl: c.req.valid("json").successCallbackUrl, - errorCallbackUrl: c.req.valid("json").errorCallbackUrl, - }), - ); - return c.json>({ - checkoutSessionId: checkout.checkoutSessionId, - checkoutUrl: checkout.checkoutUrl, - }); - }).pipe( - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }), - ), - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }), - ), - InvalidAnonymousIdError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - }), - ), - ); + const checkout = yield* Environment.provide(environment)( + sdkService.createCheckout({ + paymentProviderConfigurationProductId: + c.req.valid('json').paymentProviderConfigurationProductId, + successCallbackUrl: c.req.valid('json').successCallbackUrl, + errorCallbackUrl: c.req.valid('json').errorCallbackUrl + }) + ); + return c.json>({ + checkoutSessionId: checkout.checkoutSessionId, + checkoutUrl: checkout.checkoutUrl + }); + }).pipe( + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ), + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ), + InvalidAnonymousIdError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/sdk_getConfiguration.test.ts b/apps/web/lib/api/v1/sdk_getConfiguration.test.ts new file mode 100644 index 000000000..544f30e88 --- /dev/null +++ b/apps/web/lib/api/v1/sdk_getConfiguration.test.ts @@ -0,0 +1,148 @@ +import { + type InsertPaywall, + type InsertPaywallLocation, + type InsertPaywallProduct, + type InsertProduct, + paywallLocations, + paywallProducts, + paywalls, + products +} from '@voidhash/db'; +import { Environment, ProductType } from '@voidhash/lib/index'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { sdkPaywallResponseSchema } from './schema'; + +describe.sequential('/v1/sdk/get-paywall-by-location/:locationSlug', () => { + test('GET /v1/sdk/get-paywall-by-location/:locationSlug - success', async (t) => { + const h = await IntegrationHarness.init(t); + + const productInput: Omit = { + id: generateId('test'), + name: 'Test Product for Paywall', + type: ProductType.Subscription, + environment: Environment.Production + }; + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); + + const paywallInput: Omit = { + id: generateId('test'), + name: 'Test Paywall', + environment: Environment.Production + }; + await h.db.primary.insert(paywalls).values({ + ...paywallInput, + projectId: h.resources.project.id + }); + + const paywallProductInput: InsertPaywallProduct = { + id: generateId('test'), + paywallId: paywallInput.id, + productId: productInput.id, + displayName: 'Test Product Display Name', + enableNativePurchase: true, + enableWebCheckout: true, + order: 0 + }; + await h.db.primary.insert(paywallProducts).values(paywallProductInput); + + const locationSlug = `test-location-${generateId('test')}`; + const paywallLocationInput: Omit = { + id: generateId('test'), + name: 'Test Location', + slug: locationSlug, + defaultPaywallId: paywallInput.id, + environment: Environment.Production + }; + await h.db.primary.insert(paywallLocations).values({ + ...paywallLocationInput, + projectId: h.resources.project.id + }); + + const res = await h.get({ + url: `/v1/sdk/get-paywall-by-location/${locationSlug}`, + headers: { + 'x-publishable-key': h.resources.publishableKey.key, + 'x-app-user-id': h.resources.user.id + } + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + + expect(res.body).toBeDefined(); + + const responseBody = res.body as z.infer; + + expect(responseBody.paywallId).toBe(paywallInput.id); + expect(responseBody.paywallProducts).toHaveLength(1); + expect(responseBody.paywallProducts[0]?.productId).toBe(productInput.id); + expect(responseBody.paywallProducts[0]?.displayName).toBe( + paywallProductInput.displayName + ); + expect(responseBody.paywallProducts[0]?.price).toBe(100); + expect(responseBody.paywallProducts[0]?.nativePurchaseAvailable).toBe( + false + ); + expect(responseBody.paywallProducts[0]?.webCheckoutAvailable).toBe(true); + + t.onTestFinished(async () => { + await h.db.primary + .delete(paywallProducts) + .where(eq(paywallProducts.id, paywallProductInput.id)); + await h.db.primary + .delete(paywallLocations) + .where(eq(paywallLocations.id, paywallLocationInput.id)); + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.id, paywallInput.id)); + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + }); + }); + + test('GET /v1/sdk/get-paywall-by-location/:locationSlug - not found', async (t) => { + const h = await IntegrationHarness.init(t); + const nonExistentLocationSlug = `non-existent-location-${generateId( + 'test' + )}`; + + const res = await h.get({ + url: `/v1/sdk/get-paywall-by-location/${nonExistentLocationSlug}`, + headers: { + 'x-publishable-key': h.resources.publishableKey.key, + 'x-app-user-id': h.resources.user.id + } + }); + + expect( + res.status, + `expected 404, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + + // For 404, we expect an error object, not the sdkPaywallResponseSchema + expect(res.body).toBeDefined(); // Ensure body is defined before accessing its properties + + const errorBody = res.body as { + error: { + code: string; + docs: string; + message: string; + requestId: string; + }; + }; + + expect(errorBody.error.code).toBe('NOT_FOUND'); + expect(errorBody.error.docs).toEqual(expect.any(String)); + expect(errorBody.error.requestId).toEqual(expect.any(String)); + }); +}); diff --git a/apps/web/lib/api/v1/sdk_getConfiguration.ts b/apps/web/lib/api/v1/sdk_getConfiguration.ts new file mode 100644 index 000000000..14fcb2372 --- /dev/null +++ b/apps/web/lib/api/v1/sdk_getConfiguration.ts @@ -0,0 +1,113 @@ +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; +import { createEffectHandler } from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { SdkService } from '@/lib/services/sdk.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { + type sdkGetConfigurationResponseSchema, + sdkPaywallResponseSchema +} from './schema'; + +const route = describeRoute({ + description: 'Get paywall by location', + operationId: 'sdkGetPaywallByLocation', + security: [ + { + publishableKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(sdkPaywallResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['SDK'] +}); + +export type Route = typeof route; + +export const registerSdkGetConfiguration = (app: App) => + app.post('/v1/sdk/get-configuration', route, async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const sdkService = yield* SdkService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithPublishableKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + const configuration = yield* Environment.provide(environment)( + sdkService.getConfiguration() + ); + return c.json>({ + paywalls: configuration.paywalls.map((paywall) => { + return { + paywallId: paywall.id, + paywallProducts: paywall.paywallProducts.map( + (paywallProduct) => { + return { + paywallProductId: paywallProduct.id, + productId: paywallProduct.product.id, + displayName: paywallProduct.product.name, + nativePaymentProviderConfigurationProductId: + paywallProduct.webCheckoutPaymentProviderConfigurationProductId, + defaultWebCheckoutPaymentProviderConfigurationProductId: + paywallProduct.webCheckoutPaymentProviderConfigurationProductId, + paymentProviderConfigurationProducts: + paywallProduct.product.paymentProviderConfigurationProducts.map( + (paymentProviderConfigurationProduct) => { + return { + paymentProviderConfigurationProductId: + paymentProviderConfigurationProduct.id, + paymentProviderConfigurationId: + paymentProviderConfigurationProduct.paymentProviderConfigurationId, + configuration: + paymentProviderConfigurationProduct.configuration ?? + {} + }; + } + ) + }; + } + ) + }; + }), + paywallLocations: configuration.paywallLocations.map( + (paywallLocation) => { + return { + paywallLocationId: paywallLocation.id, + slug: paywallLocation.slug + }; + } + ), + placements: configuration.placements, + paymentProviderConfigurations: + configuration.paymentProviderConfigurations.map( + (paymentProviderConfiguration) => { + return { + paymentProviderConfigurationId: + paymentProviderConfiguration.id, + providerId: paymentProviderConfiguration.providerId + }; + } + ) + }); + }).pipe(Effect.catchTags({})) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/sdk_getCustomer.test.ts b/apps/web/lib/api/v1/sdk_getCustomer.test.ts index af1ff8dc3..7ae0a9ba1 100644 --- a/apps/web/lib/api/v1/sdk_getCustomer.test.ts +++ b/apps/web/lib/api/v1/sdk_getCustomer.test.ts @@ -1,86 +1,86 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; -import { customers } from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { ANONYMOUS_USER_ID_PREFIX } from "@/lib/core/sdk/constants"; -import { sdkCustomerResponseSchema } from "./schema"; +import { customers } from '@voidhash/db'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { ANONYMOUS_USER_ID_PREFIX } from '@/lib/core/sdk/constants'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { sdkCustomerResponseSchema } from './schema'; -describe.sequential("/v1/sdk/customers/**", async () => { - test("GET /v1/sdk/get-customer - not existing - anonymous - success", async (t) => { - const h = await IntegrationHarness.init(t); - const testAppUserId = `${ANONYMOUS_USER_ID_PREFIX}${generateId("test")}`; +describe.sequential('/v1/sdk/customers/**', () => { + test('GET /v1/sdk/get-customer - not existing - anonymous - success', async (t) => { + const h = await IntegrationHarness.init(t); + const testAppUserId = `${ANONYMOUS_USER_ID_PREFIX}${generateId('test')}`; - const res = await h.get({ - url: `/v1/sdk/get-customer`, - headers: { - "x-publishable-key": h.resources.publishableKey.key, - "x-app-user-id": testAppUserId, - }, - }); + const res = await h.get({ + url: '/v1/sdk/get-customer', + headers: { + 'x-publishable-key': h.resources.publishableKey.key, + 'x-app-user-id': testAppUserId + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer; + const responseBody = res.body as z.infer; - expect(responseBody.customerId).toBeDefined(); - expect(responseBody.email).toBeNull(); - expect(responseBody.name).toBeNull(); - expect(responseBody.appUserId).toBe(testAppUserId); + expect(responseBody.customerId).toBeDefined(); + expect(responseBody.email).toBeNull(); + expect(responseBody.name).toBeNull(); + expect(responseBody.appUserId).toBe(testAppUserId); - // Clean up the created customer - t.onTestFinished(async () => { - await h.db.primary - .delete(customers) - .where(eq(customers.appUserId, testAppUserId)); - }); - }); + // Clean up the created customer + t.onTestFinished(async () => { + await h.db.primary + .delete(customers) + .where(eq(customers.appUserId, testAppUserId)); + }); + }); - test("GET /v1/sdk/get-customer - existing - anonymous - success", async (t) => { - const h = await IntegrationHarness.init(t); - const testAppUserId = `${ANONYMOUS_USER_ID_PREFIX}${generateId("test")}`; + test('GET /v1/sdk/get-customer - existing - anonymous - success', async (t) => { + const h = await IntegrationHarness.init(t); + const testAppUserId = `${ANONYMOUS_USER_ID_PREFIX}${generateId('test')}`; - const createCustomerRes = await h.get({ - url: `/v1/sdk/get-customer`, - headers: { - "x-publishable-key": h.resources.publishableKey.key, - "x-app-user-id": testAppUserId, - }, - }); + const createCustomerRes = await h.get({ + url: '/v1/sdk/get-customer', + headers: { + 'x-publishable-key': h.resources.publishableKey.key, + 'x-app-user-id': testAppUserId + } + }); - const createCustomerResponseBody = createCustomerRes.body as z.infer< - typeof sdkCustomerResponseSchema - >; + const createCustomerResponseBody = createCustomerRes.body as z.infer< + typeof sdkCustomerResponseSchema + >; - const res = await h.get({ - url: `/v1/sdk/get-customer`, - headers: { - "x-publishable-key": h.resources.publishableKey.key, - "x-app-user-id": testAppUserId, - }, - }); + const res = await h.get({ + url: '/v1/sdk/get-customer', + headers: { + 'x-publishable-key': h.resources.publishableKey.key, + 'x-app-user-id': testAppUserId + } + }); - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); - const responseBody = res.body as z.infer; + const responseBody = res.body as z.infer; - expect(responseBody.customerId).toBe(createCustomerResponseBody.customerId); - expect(responseBody.email).toBe(createCustomerResponseBody.email); - expect(responseBody.name).toBe(createCustomerResponseBody.name); - expect(responseBody.appUserId).toBe(testAppUserId); + expect(responseBody.customerId).toBe(createCustomerResponseBody.customerId); + expect(responseBody.email).toBe(createCustomerResponseBody.email); + expect(responseBody.name).toBe(createCustomerResponseBody.name); + expect(responseBody.appUserId).toBe(testAppUserId); - // Clean up the created customer - t.onTestFinished(async () => { - await h.db.primary - .delete(customers) - .where(eq(customers.appUserId, testAppUserId)); - }); - }); + // Clean up the created customer + t.onTestFinished(async () => { + await h.db.primary + .delete(customers) + .where(eq(customers.appUserId, testAppUserId)); + }); + }); }); diff --git a/apps/web/lib/api/v1/sdk_getCustomer.ts b/apps/web/lib/api/v1/sdk_getCustomer.ts index 7993cd57a..c0370de04 100644 --- a/apps/web/lib/api/v1/sdk_getCustomer.ts +++ b/apps/web/lib/api/v1/sdk_getCustomer.ts @@ -1,87 +1,80 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { customerResponseSchema, sdkCustomerResponseSchema } from "./schema"; -import { App } from "../hono/app"; -import { z } from "zod"; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { SdkService } from "@/lib/services/sdk.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { SdkService } from '@/lib/services/sdk.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { + type customerResponseSchema, + sdkCustomerResponseSchema +} from './schema'; const route = describeRoute({ - description: "Get a customer by app user ID", - operationId: "sdkGetCustomerByAppUserId", - security: [ - { - publishableKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(sdkCustomerResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["SDK"], + description: 'Get a customer by app user ID', + operationId: 'sdkGetCustomerByAppUserId', + security: [ + { + publishableKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(sdkCustomerResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['SDK'] }); export type Route = typeof route; export const registerSdkGetCustomer = (app: App) => - app.get("/v1/sdk/get-customer", route, async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const sdkService = yield* SdkService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithPublishableKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); - const customer = yield* Environment.provide(environment)( - sdkService.getCustomerOrCreateAnonymous(), - ).pipe( - Effect.catchTags({ - InvalidAnonymousIdError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ); + app.get('/v1/sdk/get-customer', route, async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const sdkService = yield* SdkService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithPublishableKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + const customer = yield* Environment.provide(environment)( + sdkService.getCustomer() + ); - if (!customer) { - return yield* Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: "Customer not found", - }), - ); - } + if (!customer) { + return yield* Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: 'Customer not found' + }) + ); + } - return c.json>({ - customerId: customer.id, - name: customer.name ?? null, - email: customer.email, - appUserId: customer.appUserId ?? null, - // origin: customer.origin, - }); - }), - ); - }), - ), - ); + return c.json>({ + customerId: customer.id, + name: customer.name ?? null, + email: customer.email, + appUserId: customer.appUserId ?? null + // origin: customer.origin, + }); + }) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/sdk_getPaywallByLocation.test.ts b/apps/web/lib/api/v1/sdk_getPaywallByLocation.test.ts index 8629d3485..544f30e88 100644 --- a/apps/web/lib/api/v1/sdk_getPaywallByLocation.test.ts +++ b/apps/web/lib/api/v1/sdk_getPaywallByLocation.test.ts @@ -1,151 +1,148 @@ -import { generateId } from "@/lib/id/generate"; -import { eq } from "drizzle-orm"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; import { - InsertPaywall, - InsertPaywallLocation, - InsertPaywallProduct, - InsertProduct, - paywallLocations, - paywallProducts, - paywalls, - products, -} from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { sdkPaywallResponseSchema } from "./schema"; -import { Environment, ProductType } from "@voidhash/lib/index"; - -describe.sequential( - "/v1/sdk/get-paywall-by-location/:locationSlug", - async () => { - test("GET /v1/sdk/get-paywall-by-location/:locationSlug - success", async (t) => { - const h = await IntegrationHarness.init(t); - - const productInput: Omit = { - id: generateId("test"), - name: "Test Product for Paywall", - type: ProductType.Subscription, - environment: Environment.Production, - }; - await h.db.primary.insert(products).values({ - ...productInput, - projectId: h.resources.project.id, - }); - - const paywallInput: Omit = { - id: generateId("test"), - name: "Test Paywall", - environment: Environment.Production, - }; - await h.db.primary.insert(paywalls).values({ - ...paywallInput, - projectId: h.resources.project.id, - }); - - const paywallProductInput: InsertPaywallProduct = { - id: generateId("test"), - paywallId: paywallInput.id, - productId: productInput.id, - displayName: "Test Product Display Name", - enableNativePurchase: true, - enableWebCheckout: true, - order: 0, - }; - await h.db.primary.insert(paywallProducts).values(paywallProductInput); - - const locationSlug = `test-location-${generateId("test")}`; - const paywallLocationInput: Omit = { - id: generateId("test"), - name: "Test Location", - slug: locationSlug, - defaultPaywallId: paywallInput.id, - environment: Environment.Production, - }; - await h.db.primary.insert(paywallLocations).values({ - ...paywallLocationInput, - projectId: h.resources.project.id, - }); - - const res = await h.get({ - url: `/v1/sdk/get-paywall-by-location/${locationSlug}`, - headers: { - "x-publishable-key": h.resources.publishableKey.key, - "x-app-user-id": h.resources.user.id, - }, - }); - - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - - expect(res.body).toBeDefined(); - - const responseBody = res.body as z.infer; - - expect(responseBody.paywallId).toBe(paywallInput.id); - expect(responseBody.paywallProducts).toHaveLength(1); - expect(responseBody.paywallProducts[0]?.productId).toBe(productInput.id); - expect(responseBody.paywallProducts[0]?.displayName).toBe( - paywallProductInput.displayName - ); - expect(responseBody.paywallProducts[0]?.price).toBe(100); - expect(responseBody.paywallProducts[0]?.nativePurchaseAvailable).toBe( - false - ); - expect(responseBody.paywallProducts[0]?.webCheckoutAvailable).toBe(true); - - t.onTestFinished(async () => { - await h.db.primary - .delete(paywallProducts) - .where(eq(paywallProducts.id, paywallProductInput.id)); - await h.db.primary - .delete(paywallLocations) - .where(eq(paywallLocations.id, paywallLocationInput.id)); - await h.db.primary - .delete(paywalls) - .where(eq(paywalls.id, paywallInput.id)); - await h.db.primary - .delete(products) - .where(eq(products.id, productInput.id)); - }); - }); - - test("GET /v1/sdk/get-paywall-by-location/:locationSlug - not found", async (t) => { - const h = await IntegrationHarness.init(t); - const nonExistentLocationSlug = `non-existent-location-${generateId( - "test" - )}`; - - const res = await h.get({ - url: `/v1/sdk/get-paywall-by-location/${nonExistentLocationSlug}`, - headers: { - "x-publishable-key": h.resources.publishableKey.key, - "x-app-user-id": h.resources.user.id, - }, - }); - - expect( - res.status, - `expected 404, received: ${JSON.stringify(res, null, 2)}` - ).toBe(404); - - // For 404, we expect an error object, not the sdkPaywallResponseSchema - expect(res.body).toBeDefined(); // Ensure body is defined before accessing its properties - - const errorBody = res.body as { - error: { - code: string; - docs: string; - message: string; - requestId: string; - }; - }; - - expect(errorBody.error.code).toBe("NOT_FOUND"); - expect(errorBody.error.docs).toEqual(expect.any(String)); - expect(errorBody.error.requestId).toEqual(expect.any(String)); - }); - } -); + type InsertPaywall, + type InsertPaywallLocation, + type InsertPaywallProduct, + type InsertProduct, + paywallLocations, + paywallProducts, + paywalls, + products +} from '@voidhash/db'; +import { Environment, ProductType } from '@voidhash/lib/index'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import type { z } from 'zod'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import type { sdkPaywallResponseSchema } from './schema'; + +describe.sequential('/v1/sdk/get-paywall-by-location/:locationSlug', () => { + test('GET /v1/sdk/get-paywall-by-location/:locationSlug - success', async (t) => { + const h = await IntegrationHarness.init(t); + + const productInput: Omit = { + id: generateId('test'), + name: 'Test Product for Paywall', + type: ProductType.Subscription, + environment: Environment.Production + }; + await h.db.primary.insert(products).values({ + ...productInput, + projectId: h.resources.project.id + }); + + const paywallInput: Omit = { + id: generateId('test'), + name: 'Test Paywall', + environment: Environment.Production + }; + await h.db.primary.insert(paywalls).values({ + ...paywallInput, + projectId: h.resources.project.id + }); + + const paywallProductInput: InsertPaywallProduct = { + id: generateId('test'), + paywallId: paywallInput.id, + productId: productInput.id, + displayName: 'Test Product Display Name', + enableNativePurchase: true, + enableWebCheckout: true, + order: 0 + }; + await h.db.primary.insert(paywallProducts).values(paywallProductInput); + + const locationSlug = `test-location-${generateId('test')}`; + const paywallLocationInput: Omit = { + id: generateId('test'), + name: 'Test Location', + slug: locationSlug, + defaultPaywallId: paywallInput.id, + environment: Environment.Production + }; + await h.db.primary.insert(paywallLocations).values({ + ...paywallLocationInput, + projectId: h.resources.project.id + }); + + const res = await h.get({ + url: `/v1/sdk/get-paywall-by-location/${locationSlug}`, + headers: { + 'x-publishable-key': h.resources.publishableKey.key, + 'x-app-user-id': h.resources.user.id + } + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + + expect(res.body).toBeDefined(); + + const responseBody = res.body as z.infer; + + expect(responseBody.paywallId).toBe(paywallInput.id); + expect(responseBody.paywallProducts).toHaveLength(1); + expect(responseBody.paywallProducts[0]?.productId).toBe(productInput.id); + expect(responseBody.paywallProducts[0]?.displayName).toBe( + paywallProductInput.displayName + ); + expect(responseBody.paywallProducts[0]?.price).toBe(100); + expect(responseBody.paywallProducts[0]?.nativePurchaseAvailable).toBe( + false + ); + expect(responseBody.paywallProducts[0]?.webCheckoutAvailable).toBe(true); + + t.onTestFinished(async () => { + await h.db.primary + .delete(paywallProducts) + .where(eq(paywallProducts.id, paywallProductInput.id)); + await h.db.primary + .delete(paywallLocations) + .where(eq(paywallLocations.id, paywallLocationInput.id)); + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.id, paywallInput.id)); + await h.db.primary + .delete(products) + .where(eq(products.id, productInput.id)); + }); + }); + + test('GET /v1/sdk/get-paywall-by-location/:locationSlug - not found', async (t) => { + const h = await IntegrationHarness.init(t); + const nonExistentLocationSlug = `non-existent-location-${generateId( + 'test' + )}`; + + const res = await h.get({ + url: `/v1/sdk/get-paywall-by-location/${nonExistentLocationSlug}`, + headers: { + 'x-publishable-key': h.resources.publishableKey.key, + 'x-app-user-id': h.resources.user.id + } + }); + + expect( + res.status, + `expected 404, received: ${JSON.stringify(res, null, 2)}` + ).toBe(404); + + // For 404, we expect an error object, not the sdkPaywallResponseSchema + expect(res.body).toBeDefined(); // Ensure body is defined before accessing its properties + + const errorBody = res.body as { + error: { + code: string; + docs: string; + message: string; + requestId: string; + }; + }; + + expect(errorBody.error.code).toBe('NOT_FOUND'); + expect(errorBody.error.docs).toEqual(expect.any(String)); + expect(errorBody.error.requestId).toEqual(expect.any(String)); + }); +}); diff --git a/apps/web/lib/api/v1/sdk_getPaywallByLocation.ts b/apps/web/lib/api/v1/sdk_getPaywallByLocation.ts index 6a569227b..869aa6254 100644 --- a/apps/web/lib/api/v1/sdk_getPaywallByLocation.ts +++ b/apps/web/lib/api/v1/sdk_getPaywallByLocation.ts @@ -1,84 +1,88 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; -import { App } from "../hono/app"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - sdkGetPaywallByLocationParamsSchema, - sdkPaywallResponseSchema, -} from "./schema"; -import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { SdkService } from "@/lib/services/sdk.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { SdkService } from '@/lib/services/sdk.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + sdkGetPaywallByLocationParamsSchema, + sdkGetPaywallByLocationQuerySchema, + sdkPaywallResponseSchema +} from './schema'; const route = describeRoute({ - description: "Get paywall by location", - operationId: "sdkGetPaywallByLocation", - security: [ - { - publishableKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(sdkPaywallResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["SDK"], + description: 'Get paywall by location', + operationId: 'sdkGetPaywallByLocation', + security: [ + { + publishableKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(sdkPaywallResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['SDK'] }); export type Route = typeof route; export const registerSdkGetPaywallByLocation = (app: App) => - app.get( - "/v1/sdk/get-paywall-by-location/:locationSlug", - route, - zValidator("param", sdkGetPaywallByLocationParamsSchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const sdkService = yield* SdkService; - const environmentService = yield* EnvironmentService; - const authSession = - yield* authService.authenticateWithPublishableKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); - const paywall = yield* Environment.provide(environment)( - sdkService.getPaywallByLocation({ - locationSlug: c.req.param("locationSlug"), - nativePaymentProviderId: undefined, // You may need to get this from query params if needed - }) - ); - return c.json>(paywall); - }).pipe( - Effect.catchTags({ - PaywallNotFound: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }) - ), - }) - ) - ); - }) - ) - ); + app.get( + '/v1/sdk/get-paywall-by-location/:locationSlug', + route, + zValidator('param', sdkGetPaywallByLocationParamsSchema), + zValidator('query', sdkGetPaywallByLocationQuerySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const sdkService = yield* SdkService; + const environmentService = yield* EnvironmentService; + const authSession = + yield* authService.authenticateWithPublishableKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + const paywall = yield* Environment.provide(environment)( + sdkService.getPaywallByLocation({ + locationSlug: c.req.param('locationSlug'), + nativePaymentProviderId: c.req.query( + 'nativePaymentProviderId' + ) + }) + ); + return c.json>(paywall); + }).pipe( + Effect.catchTags({ + PaywallNotFound: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ) + }) + ) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/sdk_identify.test.ts b/apps/web/lib/api/v1/sdk_identify.test.ts index bba727d10..559d2a9e7 100644 --- a/apps/web/lib/api/v1/sdk_identify.test.ts +++ b/apps/web/lib/api/v1/sdk_identify.test.ts @@ -1,394 +1,396 @@ -import { generateId } from "@/lib/id/generate"; -import { IntegrationHarness } from "@/lib/testing/integration-harness"; import { - CustomerOrigin, - customers, - CustomerType, - InsertCustomer, -} from "@voidhash/db"; -import { describe, expect, test } from "vitest"; -import { sdkCustomerResponseSchema } from "./schema"; -import { eq } from "drizzle-orm"; -import { ANONYMOUS_USER_ID_PREFIX } from "@/lib/core/sdk/constants"; -import { Environment } from "@voidhash/lib/constants"; - -describe.sequential("/v1/sdk/identify", async () => { - test("POST /v1/sdk/identify - existing anonymous customer - success", async (t) => { - const h = await IntegrationHarness.init(t); - const appUserId = generateId("test"); - const name = "Test User"; - const email = "test@example.com"; - - const anonymousCustomer = { - id: generateId("test"), - projectId: h.resources.project.id, - appUserId: `${ANONYMOUS_USER_ID_PREFIX}${generateId("test")}`, - email: "initial@example.com", - type: CustomerType.Anonymous, - origin: CustomerOrigin.IOS, - environment: Environment.Production, - } as const; - // Ensure anonymous customer exists - await h.db.primary.insert(customers).values(anonymousCustomer); - - const res = await h.post({ - url: "/v1/sdk/identify", - headers: { - "x-publishable-key": h.resources.publishableKey.unhashedKey, - "x-app-user-id": anonymousCustomer.appUserId, - "Content-Type": "application/json", - }, - body: { - appUserId, - name, - email, - }, - }); - - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - - const validatedBody = sdkCustomerResponseSchema.safeParse(res.body); - expect( - validatedBody.success, - `Body validation failed: ${JSON.stringify(validatedBody.error, null, 2)}` - ).toBe(true); - - // Verify the response is correct - if (validatedBody.success) { - expect(validatedBody.data.appUserId).toBe(appUserId); - expect(validatedBody.data.name).toBe(name); - expect(validatedBody.data.email).toBe(email); - } - - // Verify the new customer is created in the database - const retrievedNewCustomer = await h.db.primary.query.customers.findFirst({ - where: eq(customers.appUserId, appUserId), - }); - expect(retrievedNewCustomer).toBeDefined(); - expect(retrievedNewCustomer?.name).toBe(name); - expect(retrievedNewCustomer?.email).toBe(email); - expect(retrievedNewCustomer?.type).toBe(CustomerType.Identified); - - // Verify the anonymous customer is archived - const retrievedAnonymousCustomer = - await h.db.primary.query.customers.findFirst({ - where: eq(customers.appUserId, anonymousCustomer.appUserId), - }); - expect(retrievedAnonymousCustomer).toBeDefined(); - expect(retrievedAnonymousCustomer?.type).toBe(CustomerType.Anonymous); - expect(retrievedAnonymousCustomer?.parentCustomerId).toBe( - retrievedNewCustomer?.id - ); - expect(retrievedAnonymousCustomer?.archivedAt).toBeDefined(); - }); - - test("POST /v1/sdk/identify - not existing anonymous customer - success", async (t) => { - const h = await IntegrationHarness.init(t); - const appUserId = generateId("test"); - const name = "Test User"; - const email = "test@example.com"; - const anonymousAppUserId = `${ANONYMOUS_USER_ID_PREFIX}${generateId("test")}`; - - const res = await h.post({ - url: "/v1/sdk/identify", - headers: { - "x-publishable-key": h.resources.publishableKey.unhashedKey, - "x-app-user-id": anonymousAppUserId, - "Content-Type": "application/json", - }, - body: { - appUserId, - name, - email, - }, - }); - - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - - const validatedBody = sdkCustomerResponseSchema.safeParse(res.body); - expect( - validatedBody.success, - `Body validation failed: ${JSON.stringify(validatedBody.error, null, 2)}` - ).toBe(true); - - // Verify the response is correct - if (validatedBody.success) { - expect(validatedBody.data.appUserId).toBe(appUserId); - expect(validatedBody.data.name).toBe(name); - expect(validatedBody.data.email).toBe(email); - } - - // Verify the new customer is created in the database - const retrievedNewCustomer = await h.db.primary.query.customers.findFirst({ - where: eq(customers.appUserId, appUserId), - }); - expect(retrievedNewCustomer).toBeDefined(); - expect(retrievedNewCustomer?.name).toBe(name); - expect(retrievedNewCustomer?.email).toBe(email); - expect(retrievedNewCustomer?.type).toBe(CustomerType.Identified); - - // Verify the anonymous customer is archived - const retrievedAnonymousCustomer = - await h.db.primary.query.customers.findFirst({ - where: eq(customers.appUserId, anonymousAppUserId), - }); - expect(retrievedAnonymousCustomer).not.toBeDefined(); - }); - - test("POST /v1/sdk/identify - existing identified customer - success", async (t) => { - const h = await IntegrationHarness.init(t); - const appUserId = generateId("test"); - const name = "Test User"; - const email = "test@example.com"; - - // Ensure customer exists for the project and appUserId - await h.db.primary.insert(customers).values({ - id: generateId("test"), - projectId: h.resources.project.id, - appUserId: appUserId, - type: CustomerType.Identified, - email: "initial@example.com", - origin: CustomerOrigin.IOS, - environment: Environment.Production, - }); - - const res = await h.post({ - url: "/v1/sdk/identify", - headers: { - "x-publishable-key": h.resources.publishableKey.unhashedKey, - "x-app-user-id": appUserId, - "Content-Type": "application/json", - }, - body: { - appUserId, - name, - email, - }, - }); - - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - - const validatedBody = sdkCustomerResponseSchema.safeParse(res.body); - expect( - validatedBody.success, - `Body validation failed: ${JSON.stringify(validatedBody.error, null, 2)}` - ).toBe(true); - - if (validatedBody.success) { - expect(validatedBody.data.appUserId).toBe(appUserId); - expect(validatedBody.data.name).toBe(null); - expect(validatedBody.data.email).toBe("initial@example.com"); - } - }); - - test("POST /v1/sdk/identify - missing appUserId", async (t) => { - const h = await IntegrationHarness.init(t); - - const res = await h.post({ - url: "/v1/sdk/identify", - headers: { - "x-publishable-key": h.resources.publishableKey.unhashedKey, - }, - body: { - name: "Test User", - email: "test@example.com", - }, - }); - // This depends on how your zValidator handles missing required fields. - // It might be a 400 or 422. - expect( - res.status, - `expected 400, received: ${JSON.stringify(res, null, 2)}` - ).toBe(400); - }); - - test("POST /v1/sdk/identify - invalid email", async (t) => { - const h = await IntegrationHarness.init(t); - const appUserId = generateId("test"); - - const res = await h.post({ - url: "/v1/sdk/identify", - headers: { - "x-publishable-key": h.resources.publishableKey.unhashedKey, - "x-app-user-id": appUserId, - "Content-Type": "application/json", - }, - body: { - appUserId, - name: "Test User", - email: "invalid-email", - }, - }); - // This depends on how your zValidator handles schema validation. - // It might be a 400 or 422. - expect( - res.status, - `expected 400, received: ${JSON.stringify(res, null, 2)}` - ).toBe(400); - }); - - test("POST /v1/sdk/identify - anonymous session (already merged), appUserId is parent", async (t) => { - const h = await IntegrationHarness.init(t); - const parentAppUserId = generateId("test"); - const parentCustomerEmail = "parent@example.com"; - const parentName = "Parent"; - - const parentCustomerValues: InsertCustomer = { - id: generateId("test"), - projectId: h.resources.project.id, - appUserId: parentAppUserId, - email: parentCustomerEmail, - name: parentName, - type: CustomerType.Identified, - origin: CustomerOrigin.IOS, - environment: Environment.Production, - }; - await h.db.primary.insert(customers).values(parentCustomerValues); - - const anonymousCustId = generateId("test"); - const anonymousAppUserId = `${ANONYMOUS_USER_ID_PREFIX}${anonymousCustId}`; - await h.db.primary.insert(customers).values({ - id: anonymousCustId, - projectId: h.resources.project.id, - appUserId: anonymousAppUserId, - type: CustomerType.Anonymous, - parentCustomerId: parentCustomerValues.id, - origin: CustomerOrigin.IOS, - environment: Environment.Production, - }); - - const res = await h.post({ - url: "/v1/sdk/identify", - headers: { - "x-publishable-key": h.resources.publishableKey.unhashedKey, - "x-app-user-id": anonymousAppUserId, - "Content-Type": "application/json", - }, - body: { - appUserId: parentAppUserId, - }, - }); - - expect( - res.status, - `expected 200, received: ${JSON.stringify(res, null, 2)}` - ).toBe(200); - - const validatedBody = sdkCustomerResponseSchema.safeParse(res.body); - expect(validatedBody.success).toBe(true); - if (!validatedBody.success) return; - - expect(validatedBody.data.customerId).toBe(parentCustomerValues.id); - expect(validatedBody.data.appUserId).toBe(parentAppUserId); - expect(validatedBody.data.email).toBe(parentCustomerEmail); - }); - - test("POST /v1/sdk/identify - anonymous session (already merged), appUserId is different", async (t) => { - const h = await IntegrationHarness.init(t); - const parentAppUserId = generateId("test"); - const parentCustomerValues: InsertCustomer = { - id: generateId("test"), - projectId: h.resources.project.id, - appUserId: parentAppUserId, - email: "p@p.com", - name: "P", - type: CustomerType.Identified, - origin: CustomerOrigin.API, - environment: Environment.Production, - }; - await h.db.primary.insert(customers).values(parentCustomerValues); - - const anonymousCustId = generateId("test"); - const anonymousAppUserId = `${ANONYMOUS_USER_ID_PREFIX}${anonymousCustId}`; - await h.db.primary.insert(customers).values({ - id: anonymousCustId, - projectId: h.resources.project.id, - appUserId: anonymousAppUserId, - type: CustomerType.Anonymous, - parentCustomerId: parentCustomerValues.id, - origin: CustomerOrigin.API, - environment: Environment.Production, - }); - - const differentAppUserId = generateId("test"); - - const res = await h.post({ - url: "/v1/sdk/identify", - headers: { - "x-publishable-key": h.resources.publishableKey.unhashedKey, - "x-app-user-id": anonymousAppUserId, - "Content-Type": "application/json", - }, - body: { - appUserId: differentAppUserId, - }, - }); - - expect( - res.status, - `expected 409, received: ${JSON.stringify(res, null, 2)}` - ).toBe(409); - }); - - test("POST /v1/sdk/identify - identified session, appUserId is different", async (t) => { - const h = await IntegrationHarness.init(t); - const initialAppUserId = generateId("test"); - - const initialCustomerValues: InsertCustomer = { - id: generateId("test"), - projectId: h.resources.project.id, - appUserId: initialAppUserId, - email: "i@i.com", - name: "Initial", - type: CustomerType.Identified, - origin: CustomerOrigin.API, - environment: Environment.Production, - }; - await h.db.primary.insert(customers).values(initialCustomerValues); - - const differentAppUserId = generateId("test"); - - const res = await h.post({ - url: "/v1/sdk/identify", - headers: { - "x-publishable-key": h.resources.publishableKey.unhashedKey, - "x-app-user-id": initialAppUserId, - "Content-Type": "application/json", - }, - body: { - appUserId: differentAppUserId, - }, - }); - - expect(res.status).toBe(200); - - const previousCustomer = await h.db.primary.query.customers.findFirst({ - where: eq(customers.appUserId, initialAppUserId), - }); - expect(previousCustomer).toBeDefined(); - expect(previousCustomer?.type).toBe(CustomerType.Identified); - expect(previousCustomer?.appUserId).toBe(initialAppUserId); - expect(previousCustomer?.email).toBe(initialCustomerValues.email); - expect(previousCustomer?.parentCustomerId).toBeNull(); - expect(previousCustomer?.archivedAt).toBeNull(); - - const newCustomer = await h.db.primary.query.customers.findFirst({ - where: eq(customers.appUserId, differentAppUserId), - }); - expect(newCustomer).toBeDefined(); - expect(newCustomer?.type).toBe(CustomerType.Identified); - expect(newCustomer?.appUserId).toBe(differentAppUserId); - expect(newCustomer?.parentCustomerId).toBeNull(); - expect(newCustomer?.archivedAt).toBeNull(); - expect(newCustomer?.id).not.toBe(previousCustomer?.id); - }); + CustomerOrigin, + CustomerType, + customers, + type InsertCustomer +} from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { eq } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; +import { ANONYMOUS_USER_ID_PREFIX } from '@/lib/core/sdk/constants'; +import { generateId } from '@/lib/id/generate'; +import { IntegrationHarness } from '@/lib/testing/integration-harness'; +import { sdkCustomerResponseSchema } from './schema'; + +describe.sequential('/v1/sdk/identify', () => { + test('POST /v1/sdk/identify - existing anonymous customer - success', async (t) => { + const h = await IntegrationHarness.init(t); + const appUserId = generateId('test'); + const name = 'Test User'; + const email = 'test@example.com'; + + const anonymousCustomer = { + id: generateId('test'), + projectId: h.resources.project.id, + appUserId: `${ANONYMOUS_USER_ID_PREFIX}${generateId('test')}`, + email: 'initial@example.com', + type: CustomerType.Anonymous, + origin: CustomerOrigin.IOS, + environment: Environment.Production + } as const; + // Ensure anonymous customer exists + await h.db.primary.insert(customers).values(anonymousCustomer); + + const res = await h.post({ + url: '/v1/sdk/identify', + headers: { + 'x-publishable-key': h.resources.publishableKey.unhashedKey, + 'x-app-user-id': anonymousCustomer.appUserId, + 'Content-Type': 'application/json' + }, + body: { + appUserId, + name, + email + } + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + + const validatedBody = sdkCustomerResponseSchema.safeParse(res.body); + expect( + validatedBody.success, + `Body validation failed: ${JSON.stringify(validatedBody.error, null, 2)}` + ).toBe(true); + + // Verify the response is correct + if (validatedBody.success) { + expect(validatedBody.data.appUserId).toBe(appUserId); + expect(validatedBody.data.name).toBe(name); + expect(validatedBody.data.email).toBe(email); + } + + // Verify the new customer is created in the database + const retrievedNewCustomer = await h.db.primary.query.customers.findFirst({ + where: eq(customers.appUserId, appUserId) + }); + expect(retrievedNewCustomer).toBeDefined(); + expect(retrievedNewCustomer?.name).toBe(name); + expect(retrievedNewCustomer?.email).toBe(email); + expect(retrievedNewCustomer?.type).toBe(CustomerType.Identified); + + // Verify the anonymous customer is archived + const retrievedAnonymousCustomer = + await h.db.primary.query.customers.findFirst({ + where: eq(customers.appUserId, anonymousCustomer.appUserId) + }); + expect(retrievedAnonymousCustomer).toBeDefined(); + expect(retrievedAnonymousCustomer?.type).toBe(CustomerType.Anonymous); + expect(retrievedAnonymousCustomer?.parentCustomerId).toBe( + retrievedNewCustomer?.id + ); + expect(retrievedAnonymousCustomer?.archivedAt).toBeDefined(); + }); + + test('POST /v1/sdk/identify - not existing anonymous customer - success', async (t) => { + const h = await IntegrationHarness.init(t); + const appUserId = generateId('test'); + const name = 'Test User'; + const email = 'test@example.com'; + const anonymousAppUserId = `${ANONYMOUS_USER_ID_PREFIX}${generateId('test')}`; + + const res = await h.post({ + url: '/v1/sdk/identify', + headers: { + 'x-publishable-key': h.resources.publishableKey.unhashedKey, + 'x-app-user-id': anonymousAppUserId, + 'Content-Type': 'application/json' + }, + body: { + appUserId, + name, + email + } + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + + const validatedBody = sdkCustomerResponseSchema.safeParse(res.body); + expect( + validatedBody.success, + `Body validation failed: ${JSON.stringify(validatedBody.error, null, 2)}` + ).toBe(true); + + // Verify the response is correct + if (validatedBody.success) { + expect(validatedBody.data.appUserId).toBe(appUserId); + expect(validatedBody.data.name).toBe(name); + expect(validatedBody.data.email).toBe(email); + } + + // Verify the new customer is created in the database + const retrievedNewCustomer = await h.db.primary.query.customers.findFirst({ + where: eq(customers.appUserId, appUserId) + }); + expect(retrievedNewCustomer).toBeDefined(); + expect(retrievedNewCustomer?.name).toBe(name); + expect(retrievedNewCustomer?.email).toBe(email); + expect(retrievedNewCustomer?.type).toBe(CustomerType.Identified); + + // Verify the anonymous customer is archived + const retrievedAnonymousCustomer = + await h.db.primary.query.customers.findFirst({ + where: eq(customers.appUserId, anonymousAppUserId) + }); + expect(retrievedAnonymousCustomer).not.toBeDefined(); + }); + + test('POST /v1/sdk/identify - existing identified customer - success', async (t) => { + const h = await IntegrationHarness.init(t); + const appUserId = generateId('test'); + const name = 'Test User'; + const email = 'test@example.com'; + + // Ensure customer exists for the project and appUserId + await h.db.primary.insert(customers).values({ + id: generateId('test'), + projectId: h.resources.project.id, + appUserId, + type: CustomerType.Identified, + email: 'initial@example.com', + origin: CustomerOrigin.IOS, + environment: Environment.Production + }); + + const res = await h.post({ + url: '/v1/sdk/identify', + headers: { + 'x-publishable-key': h.resources.publishableKey.unhashedKey, + 'x-app-user-id': appUserId, + 'Content-Type': 'application/json' + }, + body: { + appUserId, + name, + email + } + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + + const validatedBody = sdkCustomerResponseSchema.safeParse(res.body); + expect( + validatedBody.success, + `Body validation failed: ${JSON.stringify(validatedBody.error, null, 2)}` + ).toBe(true); + + if (validatedBody.success) { + expect(validatedBody.data.appUserId).toBe(appUserId); + expect(validatedBody.data.name).toBe(null); + expect(validatedBody.data.email).toBe('initial@example.com'); + } + }); + + test('POST /v1/sdk/identify - missing appUserId', async (t) => { + const h = await IntegrationHarness.init(t); + + const res = await h.post({ + url: '/v1/sdk/identify', + headers: { + 'x-publishable-key': h.resources.publishableKey.unhashedKey + }, + body: { + name: 'Test User', + email: 'test@example.com' + } + }); + // This depends on how your zValidator handles missing required fields. + // It might be a 400 or 422. + expect( + res.status, + `expected 400, received: ${JSON.stringify(res, null, 2)}` + ).toBe(400); + }); + + test('POST /v1/sdk/identify - invalid email', async (t) => { + const h = await IntegrationHarness.init(t); + const appUserId = generateId('test'); + + const res = await h.post({ + url: '/v1/sdk/identify', + headers: { + 'x-publishable-key': h.resources.publishableKey.unhashedKey, + 'x-app-user-id': appUserId, + 'Content-Type': 'application/json' + }, + body: { + appUserId, + name: 'Test User', + email: 'invalid-email' + } + }); + // This depends on how your zValidator handles schema validation. + // It might be a 400 or 422. + expect( + res.status, + `expected 400, received: ${JSON.stringify(res, null, 2)}` + ).toBe(400); + }); + + test('POST /v1/sdk/identify - anonymous session (already merged), appUserId is parent', async (t) => { + const h = await IntegrationHarness.init(t); + const parentAppUserId = generateId('test'); + const parentCustomerEmail = 'parent@example.com'; + const parentName = 'Parent'; + + const parentCustomerValues: InsertCustomer = { + id: generateId('test'), + projectId: h.resources.project.id, + appUserId: parentAppUserId, + email: parentCustomerEmail, + name: parentName, + type: CustomerType.Identified, + origin: CustomerOrigin.IOS, + environment: Environment.Production + }; + await h.db.primary.insert(customers).values(parentCustomerValues); + + const anonymousCustId = generateId('test'); + const anonymousAppUserId = `${ANONYMOUS_USER_ID_PREFIX}${anonymousCustId}`; + await h.db.primary.insert(customers).values({ + id: anonymousCustId, + projectId: h.resources.project.id, + appUserId: anonymousAppUserId, + type: CustomerType.Anonymous, + parentCustomerId: parentCustomerValues.id, + origin: CustomerOrigin.IOS, + environment: Environment.Production + }); + + const res = await h.post({ + url: '/v1/sdk/identify', + headers: { + 'x-publishable-key': h.resources.publishableKey.unhashedKey, + 'x-app-user-id': anonymousAppUserId, + 'Content-Type': 'application/json' + }, + body: { + appUserId: parentAppUserId + } + }); + + expect( + res.status, + `expected 200, received: ${JSON.stringify(res, null, 2)}` + ).toBe(200); + + const validatedBody = sdkCustomerResponseSchema.safeParse(res.body); + expect(validatedBody.success).toBe(true); + if (!validatedBody.success) { + return; + } + + expect(validatedBody.data.customerId).toBe(parentCustomerValues.id); + expect(validatedBody.data.appUserId).toBe(parentAppUserId); + expect(validatedBody.data.email).toBe(parentCustomerEmail); + }); + + test('POST /v1/sdk/identify - anonymous session (already merged), appUserId is different', async (t) => { + const h = await IntegrationHarness.init(t); + const parentAppUserId = generateId('test'); + const parentCustomerValues: InsertCustomer = { + id: generateId('test'), + projectId: h.resources.project.id, + appUserId: parentAppUserId, + email: 'p@p.com', + name: 'P', + type: CustomerType.Identified, + origin: CustomerOrigin.API, + environment: Environment.Production + }; + await h.db.primary.insert(customers).values(parentCustomerValues); + + const anonymousCustId = generateId('test'); + const anonymousAppUserId = `${ANONYMOUS_USER_ID_PREFIX}${anonymousCustId}`; + await h.db.primary.insert(customers).values({ + id: anonymousCustId, + projectId: h.resources.project.id, + appUserId: anonymousAppUserId, + type: CustomerType.Anonymous, + parentCustomerId: parentCustomerValues.id, + origin: CustomerOrigin.API, + environment: Environment.Production + }); + + const differentAppUserId = generateId('test'); + + const res = await h.post({ + url: '/v1/sdk/identify', + headers: { + 'x-publishable-key': h.resources.publishableKey.unhashedKey, + 'x-app-user-id': anonymousAppUserId, + 'Content-Type': 'application/json' + }, + body: { + appUserId: differentAppUserId + } + }); + + expect( + res.status, + `expected 409, received: ${JSON.stringify(res, null, 2)}` + ).toBe(409); + }); + + test('POST /v1/sdk/identify - identified session, appUserId is different', async (t) => { + const h = await IntegrationHarness.init(t); + const initialAppUserId = generateId('test'); + + const initialCustomerValues: InsertCustomer = { + id: generateId('test'), + projectId: h.resources.project.id, + appUserId: initialAppUserId, + email: 'i@i.com', + name: 'Initial', + type: CustomerType.Identified, + origin: CustomerOrigin.API, + environment: Environment.Production + }; + await h.db.primary.insert(customers).values(initialCustomerValues); + + const differentAppUserId = generateId('test'); + + const res = await h.post({ + url: '/v1/sdk/identify', + headers: { + 'x-publishable-key': h.resources.publishableKey.unhashedKey, + 'x-app-user-id': initialAppUserId, + 'Content-Type': 'application/json' + }, + body: { + appUserId: differentAppUserId + } + }); + + expect(res.status).toBe(200); + + const previousCustomer = await h.db.primary.query.customers.findFirst({ + where: eq(customers.appUserId, initialAppUserId) + }); + expect(previousCustomer).toBeDefined(); + expect(previousCustomer?.type).toBe(CustomerType.Identified); + expect(previousCustomer?.appUserId).toBe(initialAppUserId); + expect(previousCustomer?.email).toBe(initialCustomerValues.email); + expect(previousCustomer?.parentCustomerId).toBeNull(); + expect(previousCustomer?.archivedAt).toBeNull(); + + const newCustomer = await h.db.primary.query.customers.findFirst({ + where: eq(customers.appUserId, differentAppUserId) + }); + expect(newCustomer).toBeDefined(); + expect(newCustomer?.type).toBe(CustomerType.Identified); + expect(newCustomer?.appUserId).toBe(differentAppUserId); + expect(newCustomer?.parentCustomerId).toBeNull(); + expect(newCustomer?.archivedAt).toBeNull(); + expect(newCustomer?.id).not.toBe(previousCustomer?.id); + }); }); diff --git a/apps/web/lib/api/v1/sdk_identify.ts b/apps/web/lib/api/v1/sdk_identify.ts index 0ec6762da..9395a2533 100644 --- a/apps/web/lib/api/v1/sdk_identify.ts +++ b/apps/web/lib/api/v1/sdk_identify.ts @@ -1,101 +1,101 @@ -import { describeRoute } from "hono-openapi"; -import { resolver } from "hono-openapi/zod"; -import { openApiErrorResponses } from "../errors/openapi_responses"; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import type { z } from 'zod'; import { - customerResponseSchema, - sdkCustomerResponseSchema, - sdkIdentifyCustomerBodySchema, -} from "./schema"; -import { App } from "../hono/app"; -import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; import { - createEffectHandler, - HonoErrorResponse, -} from "@/lib/effect/runtimes/hono"; -import { SdkService } from "@/lib/services/sdk.service"; -import { Effect } from "effect"; -import { AuthService, AuthSession } from "@/lib/services/auth.service"; + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { SdkService } from '@/lib/services/sdk.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; import { - Environment, - EnvironmentService, -} from "@/lib/services/environment.service"; + type customerResponseSchema, + sdkCustomerResponseSchema, + sdkIdentifyCustomerBodySchema +} from './schema'; const route = describeRoute({ - description: - "Identifies a customer. If the customer does not exist, it will be created.", - operationId: "sdkGetCustomerByAppUserId", - security: [ - { - publishableKey: [], - }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(sdkCustomerResponseSchema) }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["SDK"], + description: + 'Identifies a customer. If the customer does not exist, it will be created.', + operationId: 'sdkGetCustomerByAppUserId', + security: [ + { + publishableKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(sdkCustomerResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['SDK'] }); export type Route = typeof route; export const registerSdkIdentify = (app: App) => - app.post( - "/v1/sdk/identify", - route, - zValidator("json", sdkIdentifyCustomerBodySchema), - async (c) => - createEffectHandler(c)( - Effect.gen(function* () { - const authService = yield* AuthService; - const sdkService = yield* SdkService; - const environmentService = yield* EnvironmentService; - const authSession = - yield* authService.authenticateWithPublishableKey(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromApiAuthSession(); - const customer = yield* Environment.provide(environment)( - sdkService.identifyCustomer({ - appUserId: c.req.valid("json").appUserId, - name: c.req.valid("json").name ?? null, - email: c.req.valid("json").email ?? null, - }) - ); - return c.json>({ - customerId: customer.id, - name: customer.name ?? null, - email: customer.email, - appUserId: customer.appUserId ?? null, - // origin: customer.origin, - }); - }).pipe( - Effect.catchTags({ - CustomerConflict: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "CONFLICT", - message: error.message, - originalError: error, - }) - ), - CustomerCreation: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - originalError: error, - }) - ), - }) - ) - ); - }) - ) - ); + app.post( + '/v1/sdk/identify', + route, + zValidator('json', sdkIdentifyCustomerBodySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const sdkService = yield* SdkService; + const environmentService = yield* EnvironmentService; + const authSession = + yield* authService.authenticateWithPublishableKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + const customer = yield* Environment.provide(environment)( + sdkService.identifyCustomer({ + appUserId: c.req.valid('json').appUserId, + name: c.req.valid('json').name ?? null, + email: c.req.valid('json').email ?? null + }) + ); + return c.json>({ + customerId: customer.id, + name: customer.name ?? null, + email: customer.email, + appUserId: customer.appUserId ?? null + // origin: customer.origin, + }); + }).pipe( + Effect.catchTags({ + CustomerConflict: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'CONFLICT', + message: error.message, + originalError: error + }) + ), + CustomerCreation: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + originalError: error + }) + ) + }) + ) + ); + }) + ) + ); diff --git a/apps/web/lib/api/v1/sdk_syncCustomerAttributes.ts b/apps/web/lib/api/v1/sdk_syncCustomerAttributes.ts new file mode 100644 index 000000000..75636e84b --- /dev/null +++ b/apps/web/lib/api/v1/sdk_syncCustomerAttributes.ts @@ -0,0 +1,83 @@ +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import { + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { SdkService } from '@/lib/services/sdk.service'; +import { openApiErrorResponses } from '../errors/openapi_responses'; +import type { App } from '../hono/app'; +import { + sdkCustomerResponseSchema, + sdkSyncCustomerAttributesBodySchema +} from './schema'; + +const route = describeRoute({ + description: 'Get a customer by app user ID', + operationId: 'sdkGetCustomerByAppUserId', + security: [ + { + publishableKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { schema: resolver(sdkCustomerResponseSchema) } + } + }, + ...openApiErrorResponses + }, + tags: ['SDK'] +}); + +export type Route = typeof route; + +export const registerSdkSyncCustomerAttributes = (app: App) => + app.post( + '/v1/sdk/sync-customer-attributes', + route, + zValidator('json', sdkSyncCustomerAttributesBodySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const sdkService = yield* SdkService; + const environmentService = yield* EnvironmentService; + const authSession = + yield* authService.authenticateWithPublishableKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + const customer = yield* Environment.provide(environment)( + sdkService.syncCustomerAttributes({ + name: c.req.valid('json').name, + email: c.req.valid('json').email + }) + ); + + return c.json({}); + }).pipe( + Effect.catchTags({ + InvalidAnonymousIdError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + }) + ) + ); diff --git a/apps/web/lib/cache-adapter.ts b/apps/web/lib/cache-adapter.ts index 898439601..0cd1bfa38 100644 --- a/apps/web/lib/cache-adapter.ts +++ b/apps/web/lib/cache-adapter.ts @@ -3,25 +3,25 @@ * Allows for different implementations to be swapped in as needed */ export interface CacheAdapter { - /** - * Cache a function's result with a given key and options - * @param fn - Function to cache the results of - * @param keys - Array of strings to use as cache keys - * @param options - Cache options including tags and revalidation time - * @returns A function that returns the cached value or recalculates it - */ - cacheFn( - fn: (...args: TArgs) => Promise, - keys: string[], - options?: { - tags?: string[]; - revalidate?: number; - } - ): (...args: TArgs) => Promise; + /** + * Cache a function's result with a given key and options + * @param fn - Function to cache the results of + * @param keys - Array of strings to use as cache keys + * @param options - Cache options including tags and revalidation time + * @returns A function that returns the cached value or recalculates it + */ + cacheFn( + fn: (...args: TArgs) => Promise, + keys: string[], + options?: { + tags?: string[]; + revalidate?: number; + } + ): (...args: TArgs) => Promise; - /** - * Invalidate the cache for a given key - * @param key - The key to invalidate - */ - invalidate(key: string): void; + /** + * Invalidate the cache for a given key + * @param key - The key to invalidate + */ + invalidate(key: string): void; } diff --git a/apps/web/lib/cookies-adapter.ts b/apps/web/lib/cookies-adapter.ts index a360ebd9e..591dfd0a6 100644 --- a/apps/web/lib/cookies-adapter.ts +++ b/apps/web/lib/cookies-adapter.ts @@ -1,5 +1,5 @@ export interface CookiesAdapter { - get(name: string): Promise; - set(name: string, value: string): Promise; - delete(name: string): Promise; + get(name: string): Promise; + set(name: string, value: string): Promise; + delete(name: string): Promise; } diff --git a/apps/web/lib/core/api-keys/effect/utils.ts b/apps/web/lib/core/api-keys/effect/utils.ts index c7732433f..db895c69e 100644 --- a/apps/web/lib/core/api-keys/effect/utils.ts +++ b/apps/web/lib/core/api-keys/effect/utils.ts @@ -1,101 +1,99 @@ -import { base64Url } from "@voidhash/lib/functions"; -import { createHash } from "@voidhash/lib/effect"; -import { Effect } from "effect"; -import { Environment, EnvironmentValue } from "@voidhash/lib/constants"; +import { Environment, type EnvironmentValue } from '@voidhash/lib/constants'; +import { createHash } from '@voidhash/lib/effect'; +import { base64Url } from '@voidhash/lib/functions'; +import { Effect } from 'effect'; export type SecretKey = { - id: string; - key: string; - isPublic: false; - end: string; - prefix: string; - environment: EnvironmentValue; + id: string; + key: string; + isPublic: false; + end: string; + prefix: string; + environment: EnvironmentValue; }; export type PublishableKey = { - id: string; - key: string; - isPublic: true; - end: string; - prefix: string; - environment: EnvironmentValue; + id: string; + key: string; + isPublic: true; + end: string; + prefix: string; + environment: EnvironmentValue; }; -export const PRODUCTION_SECRET_KEY_PREFIX = "vh_sk_"; -export const TESTING_SECRET_KEY_PREFIX = "vh_sk_test_"; -export const PRODUCTION_PUBLISHABLE_KEY_PREFIX = "vh_pk_"; -export const TESTING_PUBLISHABLE_KEY_PREFIX = "vh_pk_test_"; +export const PRODUCTION_SECRET_KEY_PREFIX = 'vh_sk_'; +export const TESTING_SECRET_KEY_PREFIX = 'vh_sk_test_'; +export const PRODUCTION_PUBLISHABLE_KEY_PREFIX = 'vh_pk_'; +export const TESTING_PUBLISHABLE_KEY_PREFIX = 'vh_pk_test_'; export const KEY_END_LENGTH = 4; const keyGenerator = (options: { - length: number; - prefix: string | undefined; + length: number; + prefix: string | undefined; }) => - Effect.gen(function* () { - const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - let apiKey = `${options.prefix || ""}`; - for (let i = 0; i < options.length; i++) { - const randomIndex = Math.floor(Math.random() * characters.length); - apiKey += characters[randomIndex]; - } - - return apiKey; - }); + Effect.sync(() => { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + let apiKey = `${options.prefix || ''}`; + for (const _ of Array.from({ length: options.length })) { + apiKey += characters[Math.floor(Math.random() * characters.length)]; + } + return apiKey; + }); export const hashKey = (key: string) => - createHash("SHA-256").pipe( - Effect.flatMap((hashingFn) => hashingFn.digest(key)), - Effect.map((hash) => base64Url.encode(hash, { padding: false })) - ); + createHash('SHA-256').pipe( + Effect.flatMap((hashingFn) => hashingFn.digest(key)), + Effect.map((hash) => base64Url.encode(hash, { padding: false })) + ); export const generateSecretKey = (environment: EnvironmentValue) => - keyGenerator({ - length: 32, - prefix: - environment === Environment.Production - ? PRODUCTION_SECRET_KEY_PREFIX - : TESTING_SECRET_KEY_PREFIX, - }); + keyGenerator({ + length: 32, + prefix: + environment === Environment.Production + ? PRODUCTION_SECRET_KEY_PREFIX + : TESTING_SECRET_KEY_PREFIX + }); export const generatePublishableKey = (environment: EnvironmentValue) => - keyGenerator({ - length: 32, - prefix: - environment === Environment.Production - ? PRODUCTION_PUBLISHABLE_KEY_PREFIX - : TESTING_PUBLISHABLE_KEY_PREFIX, - }); + keyGenerator({ + length: 32, + prefix: + environment === Environment.Production + ? PRODUCTION_PUBLISHABLE_KEY_PREFIX + : TESTING_PUBLISHABLE_KEY_PREFIX + }); export const createPublishableKey = (environment: EnvironmentValue) => - generatePublishableKey(environment).pipe( - Effect.map((key) => ({ - key: key, - rawKey: key, - environment: environment, - isPublic: true, - end: key.slice(-KEY_END_LENGTH), - prefix: - environment === Environment.Production - ? PRODUCTION_PUBLISHABLE_KEY_PREFIX - : TESTING_PUBLISHABLE_KEY_PREFIX, - })) - ); + generatePublishableKey(environment).pipe( + Effect.map((key) => ({ + key, + rawKey: key, + environment, + isPublic: true, + end: key.slice(-KEY_END_LENGTH), + prefix: + environment === Environment.Production + ? PRODUCTION_PUBLISHABLE_KEY_PREFIX + : TESTING_PUBLISHABLE_KEY_PREFIX + })) + ); export const createSecretKey = (environment: EnvironmentValue) => - Effect.gen(function* () { - const key = yield* generateSecretKey(environment); - const hashed = yield* hashKey(key); - const end = key.slice(key.length - KEY_END_LENGTH); + Effect.gen(function* () { + const key = yield* generateSecretKey(environment); + const hashed = yield* hashKey(key); + const end = key.slice(key.length - KEY_END_LENGTH); - return { - key: hashed, - rawKey: key, - environment: environment, - isPublic: false, - end: end, - prefix: - environment === Environment.Production - ? PRODUCTION_SECRET_KEY_PREFIX - : TESTING_SECRET_KEY_PREFIX, - }; - }); + return { + key: hashed, + rawKey: key, + environment, + isPublic: false, + end, + prefix: + environment === Environment.Production + ? PRODUCTION_SECRET_KEY_PREFIX + : TESTING_SECRET_KEY_PREFIX + }; + }); diff --git a/apps/web/lib/core/api-keys/types.ts b/apps/web/lib/core/api-keys/types.ts index 91c0750e9..1a125c80c 100644 --- a/apps/web/lib/core/api-keys/types.ts +++ b/apps/web/lib/core/api-keys/types.ts @@ -1,10 +1,10 @@ -import { EnvironmentValue } from "@voidhash/lib/index"; +import type { EnvironmentValue } from '@voidhash/lib/index'; export type ApiKey = { - key: string; - rawKey?: string; - environment: EnvironmentValue; - isPublic: boolean; - end: string; - prefix: string; + key: string; + rawKey?: string; + environment: EnvironmentValue; + isPublic: boolean; + end: string; + prefix: string; }; diff --git a/apps/web/lib/core/api-keys/utils.ts b/apps/web/lib/core/api-keys/utils.ts index f44228666..a3a8750b4 100644 --- a/apps/web/lib/core/api-keys/utils.ts +++ b/apps/web/lib/core/api-keys/utils.ts @@ -1,116 +1,117 @@ -import { base64Url } from "@voidhash/lib/functions"; +import { createHash } from '@voidhash/lib'; +import { base64Url } from '@voidhash/lib/functions'; import { - EnvironmentValue, - Environment as EnvironmentEnum, -} from "@voidhash/lib/index"; -import { createHash } from "@voidhash/lib"; -import { ApiKey } from "./types"; - -const keyGenerator = async (options: { - length: number; - prefix: string | undefined; + Environment as EnvironmentEnum, + type EnvironmentValue +} from '@voidhash/lib/index'; +import type { ApiKey } from './types'; + +const keyGenerator = (options: { + length: number; + prefix: string | undefined; }) => { - const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - let apiKey = `${options.prefix || ""}`; - for (let i = 0; i < options.length; i++) { - const randomIndex = Math.floor(Math.random() * characters.length); - apiKey += characters[randomIndex]; - } - - return apiKey; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + let apiKey = `${options.prefix || ''}`; + + for (const _ of new Array(options.length)) { + const randomIndex = Math.floor(Math.random() * characters.length); + apiKey += characters[randomIndex]; + } + + return apiKey; }; export type SecretKey = { - id: string; - key: string; - isPublic: false; - end: string; - prefix: string; - environment: EnvironmentValue; + id: string; + key: string; + isPublic: false; + end: string; + prefix: string; + environment: EnvironmentValue; }; export type PublishableKey = { - id: string; - key: string; - isPublic: true; - end: string; - prefix: string; - environment: EnvironmentValue; + id: string; + key: string; + isPublic: true; + end: string; + prefix: string; + environment: EnvironmentValue; }; -export const PRODUCTION_SECRET_KEY_PREFIX = "vh_sk_"; -export const TESTING_SECRET_KEY_PREFIX = "vh_sk_test_"; -export const PRODUCTION_PUBLISHABLE_KEY_PREFIX = "vh_pk_"; -export const TESTING_PUBLISHABLE_KEY_PREFIX = "vh_pk_test_"; +export const PRODUCTION_SECRET_KEY_PREFIX = 'vh_sk_'; +export const TESTING_SECRET_KEY_PREFIX = 'vh_sk_test_'; +export const PRODUCTION_PUBLISHABLE_KEY_PREFIX = 'vh_pk_'; +export const TESTING_PUBLISHABLE_KEY_PREFIX = 'vh_pk_test_'; async function generateSecretKey(environment: EnvironmentValue) { - const key = await keyGenerator({ - length: 32, - prefix: - environment === EnvironmentEnum.Production - ? PRODUCTION_SECRET_KEY_PREFIX - : TESTING_SECRET_KEY_PREFIX, - }); - - return key; + const key = await keyGenerator({ + length: 32, + prefix: + environment === EnvironmentEnum.Production + ? PRODUCTION_SECRET_KEY_PREFIX + : TESTING_SECRET_KEY_PREFIX + }); + + return key; } async function generatePublishableKey(environment: EnvironmentValue) { - const key = await keyGenerator({ - length: 32, - prefix: - environment === EnvironmentEnum.Production - ? PRODUCTION_PUBLISHABLE_KEY_PREFIX - : TESTING_PUBLISHABLE_KEY_PREFIX, - }); - - return key; + const key = await keyGenerator({ + length: 32, + prefix: + environment === EnvironmentEnum.Production + ? PRODUCTION_PUBLISHABLE_KEY_PREFIX + : TESTING_PUBLISHABLE_KEY_PREFIX + }); + + return key; } export const createPublishableKey = async ( - environment: EnvironmentValue + environment: EnvironmentValue ): Promise => { - const key = await generatePublishableKey(environment); - return { - key: key, - rawKey: key, - environment: environment, - isPublic: true, - end: key.slice(-KEY_END_LENGTH), - prefix: - environment === EnvironmentEnum.Production - ? PRODUCTION_PUBLISHABLE_KEY_PREFIX - : TESTING_PUBLISHABLE_KEY_PREFIX, - }; + const key = await generatePublishableKey(environment); + return { + key, + rawKey: key, + environment, + isPublic: true, + end: key.slice(-KEY_END_LENGTH), + prefix: + environment === EnvironmentEnum.Production + ? PRODUCTION_PUBLISHABLE_KEY_PREFIX + : TESTING_PUBLISHABLE_KEY_PREFIX + }; }; export const createSecretKey = async ( - environment: EnvironmentValue + environment: EnvironmentValue ): Promise => { - const key = await generateSecretKey(environment); - const hashed = await hashKey(key); - - const end = key.slice(key.length - KEY_END_LENGTH); - - return { - key: hashed, - rawKey: key, - environment: environment, - isPublic: false, - end: end, - prefix: - environment === EnvironmentEnum.Production - ? PRODUCTION_SECRET_KEY_PREFIX - : TESTING_SECRET_KEY_PREFIX, - }; + const key = await generateSecretKey(environment); + const hashed = await hashKey(key); + + const end = key.slice(key.length - KEY_END_LENGTH); + + return { + key: hashed, + rawKey: key, + environment, + isPublic: false, + end, + prefix: + environment === EnvironmentEnum.Production + ? PRODUCTION_SECRET_KEY_PREFIX + : TESTING_SECRET_KEY_PREFIX + }; }; export const hashKey = async (key: string) => { - const hash = await createHash("SHA-256").digest(key); - const hashed = base64Url.encode(hash, { - padding: false, - }); - return hashed; + const hash = await createHash('SHA-256').digest(key); + const hashed = base64Url.encode(hash, { + padding: false + }); + return hashed; }; export const KEY_END_LENGTH = 4; diff --git a/apps/web/lib/core/environments/utils.ts b/apps/web/lib/core/environments/utils.ts index 0c581839e..ff3c2c1c7 100644 --- a/apps/web/lib/core/environments/utils.ts +++ b/apps/web/lib/core/environments/utils.ts @@ -1,63 +1,65 @@ import { - fromUnknownThrow, - VoidhashInternalServerError, - VoidhashNotFoundError, -} from "@voidhash/lib/constants"; -import { type EnvironmentValue } from "@voidhash/lib/index"; -import { CookiesAdapter } from "@/lib/cookies-adapter"; -import { err, ok, Result, ResultAsync } from "neverthrow"; -import { cache } from "react"; + fromUnknownThrow, + type VoidhashInternalServerError, + type VoidhashNotFoundError +} from '@voidhash/lib/constants'; +import type { EnvironmentValue } from '@voidhash/lib/index'; +import { err, ok, type Result, ResultAsync } from 'neverthrow'; +import { cache } from 'react'; +import type { CookiesAdapter } from '@/lib/cookies-adapter'; export const getEnvironment = cache( - async ( - cookies: CookiesAdapter, - organizationSlug: string, - projectSlug: string - ): Promise< - Result< - EnvironmentValue, - VoidhashInternalServerError | VoidhashNotFoundError - > - > => { - const projectEnvironmentCookie = await ResultAsync.fromPromise( - cookies.get(`project_environment_${organizationSlug}:${projectSlug}`), - (e) => fromUnknownThrow(e) - ); + async ( + cookies: CookiesAdapter, + organizationSlug: string, + projectSlug: string + ): Promise< + Result< + EnvironmentValue, + VoidhashInternalServerError | VoidhashNotFoundError + > + > => { + const projectEnvironmentCookie = await ResultAsync.fromPromise( + cookies.get(`project_environment_${organizationSlug}:${projectSlug}`), + (e) => fromUnknownThrow(e) + ); - if (projectEnvironmentCookie.isErr()) { - return err(projectEnvironmentCookie.error); - } + if (projectEnvironmentCookie.isErr()) { + return err(projectEnvironmentCookie.error); + } - if (!projectEnvironmentCookie.value) { - return err({ - code: "NOT_FOUND", - message: "Project environment not found", - resource: "projectEnvironment", - payload: { organizationSlug, projectSlug }, - }); - } + if (!projectEnvironmentCookie.value) { + return err({ + code: 'NOT_FOUND', + message: 'Project environment not found', + resource: 'projectEnvironment', + payload: { organizationSlug, projectSlug } + }); + } - return ok(parseInt(projectEnvironmentCookie.value) as EnvironmentValue); - } + return ok( + Number.parseInt(projectEnvironmentCookie.value, 10) as EnvironmentValue + ); + } ); export async function setEnvironment( - cookies: CookiesAdapter, - organizationSlug: string, - projectSlug: string, - environment: EnvironmentValue + cookies: CookiesAdapter, + organizationSlug: string, + projectSlug: string, + environment: EnvironmentValue ): Promise> { - const res = await ResultAsync.fromPromise( - cookies.set( - `project_environment_${organizationSlug}:${projectSlug}`, - environment.toString() - ), - (e) => fromUnknownThrow(e) - ); + const res = await ResultAsync.fromPromise( + cookies.set( + `project_environment_${organizationSlug}:${projectSlug}`, + environment.toString() + ), + (e) => fromUnknownThrow(e) + ); - if (res.isErr()) { - return err(res.error); - } + if (res.isErr()) { + return err(res.error); + } - return ok(undefined); + return ok(undefined); } diff --git a/apps/web/lib/core/organizations/permissions.ts b/apps/web/lib/core/organizations/permissions.ts index aec2ec785..52497e93b 100644 --- a/apps/web/lib/core/organizations/permissions.ts +++ b/apps/web/lib/core/organizations/permissions.ts @@ -1,6 +1,6 @@ export const OrganizationPermissions = { - all: "organization:all", + all: 'organization:all' } as const; export type OrganizationPermission = - (typeof OrganizationPermissions)[keyof typeof OrganizationPermissions]; + (typeof OrganizationPermissions)[keyof typeof OrganizationPermissions]; diff --git a/apps/web/lib/core/payment-providers/base-payment-provider.ts b/apps/web/lib/core/payment-providers/base-payment-provider.ts index e7664fcf3..44ec64716 100644 --- a/apps/web/lib/core/payment-providers/base-payment-provider.ts +++ b/apps/web/lib/core/payment-providers/base-payment-provider.ts @@ -1,77 +1,77 @@ -import { EnvironmentValue } from "@voidhash/lib/constants"; -import { z } from "zod"; +import type { EnvironmentValue } from '@voidhash/lib/constants'; +import type { z } from 'zod'; export class BasePaymentProvider< - TKey extends string, - TGlobalConfigurationSchema extends z.ZodSchema, - TProductConfigurationSchema extends z.ZodSchema, + TKey extends string, + TGlobalConfigurationSchema extends z.ZodSchema, + TProductConfigurationSchema extends z.ZodSchema > { - private _id: TKey; - private _title: string; - private _environments: EnvironmentValue[]; - private _globalConfigurationKeyProperties: (keyof z.infer)[]; - private _productKeyProperties: (keyof z.infer)[]; - private _type: "native" | "web-checkout"; - // Configuration is optional for payment providers that don't require configuration - e.g. Dev Checkout + private _id: TKey; + private _title: string; + private _environments: EnvironmentValue[]; + private _globalConfigurationKeyProperties: (keyof z.infer)[]; + private _productKeyProperties: (keyof z.infer)[]; + private _type: 'native' | 'web-checkout'; + // Configuration is optional for payment providers that don't require configuration - e.g. Dev Checkout - constructor( - id: TKey, - title: string, - environments: EnvironmentValue[], - globalConfigurationKeyProperties: (keyof z.infer)[], - productKeyProperties: (keyof z.infer)[], - type: "native" | "web-checkout" - ) { - this._id = id; - this._title = title; - this._environments = environments; - this._globalConfigurationKeyProperties = globalConfigurationKeyProperties; - this._productKeyProperties = productKeyProperties; - this._type = type; - } + constructor( + id: TKey, + title: string, + environments: EnvironmentValue[], + globalConfigurationKeyProperties: (keyof z.infer)[], + productKeyProperties: (keyof z.infer)[], + type: 'native' | 'web-checkout' + ) { + this._id = id; + this._title = title; + this._environments = environments; + this._globalConfigurationKeyProperties = globalConfigurationKeyProperties; + this._productKeyProperties = productKeyProperties; + this._type = type; + } - public getId() { - return this._id; - } + getId() { + return this._id; + } - public getTitle() { - return this._title; - } + getTitle() { + return this._title; + } - public getType() { - return this._type; - } + getType() { + return this._type; + } - public isAvailableInEnvironment(environment: EnvironmentValue) { - return this._environments.includes(environment); - } + isAvailableInEnvironment(environment: EnvironmentValue) { + return this._environments.includes(environment); + } - public createGlobalKey( - configuration: Partial> - ): string { - return this._globalConfigurationKeyProperties - .map((key) => configuration[key]) - .join(":"); - } + createGlobalKey( + configuration: Partial> + ): string { + return this._globalConfigurationKeyProperties + .map((key) => configuration[key]) + .join(':'); + } - public createProductKey( - configuration: z.infer - ): string { - return this._productKeyProperties - .map((key) => configuration[key]) - .join(":"); - } + createProductKey( + configuration: z.infer + ): string { + return this._productKeyProperties + .map((key) => configuration[key]) + .join(':'); + } - public getProductKeyProperties(): (keyof z.infer)[] { - return this._productKeyProperties; - } + getProductKeyProperties(): (keyof z.infer)[] { + return this._productKeyProperties; + } - // public isCorrectlyConfigured(configuration: TConfiguration) { - // if (!this.configuration) { - // return true; - // } - // const configurationSchema = this.configuration.configurationSchema; - // const parsedConfiguration = configurationSchema.safeParse(configuration); - // return parsedConfiguration.success; - // } + // public isCorrectlyConfigured(configuration: TConfiguration) { + // if (!this.configuration) { + // return true; + // } + // const configurationSchema = this.configuration.configurationSchema; + // const parsedConfiguration = configurationSchema.safeParse(configuration); + // return parsedConfiguration.success; + // } } diff --git a/apps/web/lib/core/payment-providers/payment-provider-api.ts b/apps/web/lib/core/payment-providers/payment-provider-api.ts index 4a09711f7..eac98f002 100644 --- a/apps/web/lib/core/payment-providers/payment-provider-api.ts +++ b/apps/web/lib/core/payment-providers/payment-provider-api.ts @@ -1,7 +1,7 @@ -import { App } from "@/lib/api/hono/app"; +import type { App } from '@/lib/api/hono/app'; export const createPaymentProviderApi = (api: { - registerEndpoints: (app: App) => void; + registerEndpoints: (app: App) => void; }) => { - return api; + return api; }; diff --git a/apps/web/lib/core/payment-providers/payment-provider-configuration.ts b/apps/web/lib/core/payment-providers/payment-provider-configuration.ts index 5abafc8a3..0eb07a43a 100644 --- a/apps/web/lib/core/payment-providers/payment-provider-configuration.ts +++ b/apps/web/lib/core/payment-providers/payment-provider-configuration.ts @@ -1,81 +1,81 @@ -import { z } from "zod"; -import { - CreateConfigurationSheetParams, - CreateProductEditorSheetParams, - PaymentProviderConfigurationSheetSection, - PaymentProviderProductEditorSheetSection, -} from "./types"; +import type { z } from 'zod'; +import type { + CreateConfigurationSheetParams, + CreateProductEditorSheetParams, + PaymentProviderConfigurationSheetSection, + PaymentProviderProductEditorSheetSection +} from './types'; export class PaymentProviderConfiguration< - TGlobalConfiguration, - TGlobalConfigurationSchema extends z.ZodSchema, - TProductConfiguration, - TProductConfigurationSchema extends z.ZodSchema, + TGlobalConfiguration, + TGlobalConfigurationSchema extends z.ZodSchema, + TProductConfiguration, + TProductConfigurationSchema extends z.ZodSchema > { - // Global configuration - /** - * The default global configuration is used to create a new global configuration. - */ - defaultGlobalConfiguration: TGlobalConfiguration; + // Global configuration + /** + * The default global configuration is used to create a new global configuration. + */ + defaultGlobalConfiguration: TGlobalConfiguration; - /** - * The global configuration schema is used to validate the global configuration. - */ - globalConfigurationSchema: TGlobalConfigurationSchema; + /** + * The global configuration schema is used to validate the global configuration. + */ + globalConfigurationSchema: TGlobalConfigurationSchema; - /** - * Used to create sheet for setting up global configuration. Each payment provider will have a different sheet. - */ - createGlobalConfigurationSheet: (params: CreateConfigurationSheetParams) => { - sections: PaymentProviderConfigurationSheetSection[]; - }; + /** + * Used to create sheet for setting up global configuration. Each payment provider will have a different sheet. + */ + createGlobalConfigurationSheet: (params: CreateConfigurationSheetParams) => { + sections: PaymentProviderConfigurationSheetSection[]; + }; - // Product configurations - /** - * The product key properties are used to map the payment provider product to our internal product. - * For example, stripe would be ["productId", "priceId"] - */ - productKeyProperties: string[]; + // Product configurations + /** + * The product key properties are used to map the payment provider product to our internal product. + * For example, stripe would be ["productId", "priceId"] + */ + productKeyProperties: string[]; - /** - * The default product configuration is used to create a new product configuration. - */ - defaultProductConfiguration: TProductConfiguration; + /** + * The default product configuration is used to create a new product configuration. + */ + defaultProductConfiguration: TProductConfiguration; - /** - * The product configuration schema is used to validate the product configuration. - */ - productConfigurationSchema: TProductConfigurationSchema; + /** + * The product configuration schema is used to validate the product configuration. + */ + productConfigurationSchema: TProductConfigurationSchema; - /** - * Used to create sheet for setting up product configuration. Each payment provider will have a different sheet. - */ - createProductEditorSheet: (params: CreateProductEditorSheetParams) => { - sections: PaymentProviderProductEditorSheetSection[]; - }; + /** + * Used to create sheet for setting up product configuration. Each payment provider will have a different sheet. + */ + createProductEditorSheet: (params: CreateProductEditorSheetParams) => { + sections: PaymentProviderProductEditorSheetSection[]; + }; - constructor(options: { - defaultGlobalConfiguration: TGlobalConfiguration; - globalConfigurationSchema: TGlobalConfigurationSchema; - createGlobalConfigurationSheet: ( - params: CreateConfigurationSheetParams - ) => { - sections: PaymentProviderConfigurationSheetSection[]; - }; - productKeyProperties: string[]; - defaultProductConfiguration: TProductConfiguration; - productConfigurationSchema: TProductConfigurationSchema; - createProductEditorSheet: (params: CreateProductEditorSheetParams) => { - sections: PaymentProviderProductEditorSheetSection[]; - }; - }) { - this.defaultGlobalConfiguration = options.defaultGlobalConfiguration; - this.globalConfigurationSchema = options.globalConfigurationSchema; - this.createGlobalConfigurationSheet = - options.createGlobalConfigurationSheet; - this.productKeyProperties = options.productKeyProperties; - this.defaultProductConfiguration = options.defaultProductConfiguration; - this.productConfigurationSchema = options.productConfigurationSchema; - this.createProductEditorSheet = options.createProductEditorSheet; - } + constructor(options: { + defaultGlobalConfiguration: TGlobalConfiguration; + globalConfigurationSchema: TGlobalConfigurationSchema; + createGlobalConfigurationSheet: ( + params: CreateConfigurationSheetParams + ) => { + sections: PaymentProviderConfigurationSheetSection[]; + }; + productKeyProperties: string[]; + defaultProductConfiguration: TProductConfiguration; + productConfigurationSchema: TProductConfigurationSchema; + createProductEditorSheet: (params: CreateProductEditorSheetParams) => { + sections: PaymentProviderProductEditorSheetSection[]; + }; + }) { + this.defaultGlobalConfiguration = options.defaultGlobalConfiguration; + this.globalConfigurationSchema = options.globalConfigurationSchema; + this.createGlobalConfigurationSheet = + options.createGlobalConfigurationSheet; + this.productKeyProperties = options.productKeyProperties; + this.defaultProductConfiguration = options.defaultProductConfiguration; + this.productConfigurationSchema = options.productConfigurationSchema; + this.createProductEditorSheet = options.createProductEditorSheet; + } } diff --git a/apps/web/lib/core/payment-providers/payment-provider.ts b/apps/web/lib/core/payment-providers/payment-provider.ts index 991c77c80..c515e51c2 100644 --- a/apps/web/lib/core/payment-providers/payment-provider.ts +++ b/apps/web/lib/core/payment-providers/payment-provider.ts @@ -1,57 +1,57 @@ -import { EnvironmentValue } from "@voidhash/lib/constants"; -import { - PaymentProviderConfigurationSheetSection, - PaymentProviderProductEditorSheetSection, -} from "./types"; -import { z } from "zod"; +import type { EnvironmentValue } from '@voidhash/lib/constants'; +import type { z } from 'zod'; +import type { + PaymentProviderConfigurationSheetSection, + PaymentProviderProductEditorSheetSection +} from './types'; export interface PaymentProvider< - TKey extends string, - TGlobalConfigurationSchema extends z.ZodSchema, - TProductConfigurationSchema extends z.ZodSchema, + TKey extends string, + TGlobalConfigurationSchema extends z.ZodSchema, + TProductConfigurationSchema extends z.ZodSchema > { - getId(): TKey; - getTitle(): string; - isAvailableInEnvironment(environment: EnvironmentValue): boolean; - getType(): "native" | "web-checkout"; + getId(): TKey; + getTitle(): string; + isAvailableInEnvironment(environment: EnvironmentValue): boolean; + getType(): 'native' | 'web-checkout'; - // Global configuration - getIsConfigurable(): boolean; // This is true for almost all payment providers, except for Dev Checkout - getDefaultGlobalConfiguration(): Partial>; - getGlobalConfigurationSchema(): TGlobalConfigurationSchema; - getGlobalConfigurationSheet({ projectId }: { projectId: string }): { - sections: PaymentProviderConfigurationSheetSection[]; - }; + // Global configuration + getIsConfigurable(): boolean; // This is true for almost all payment providers, except for Dev Checkout + getDefaultGlobalConfiguration(): Partial>; + getGlobalConfigurationSchema(): TGlobalConfigurationSchema; + getGlobalConfigurationSheet({ projectId }: { projectId: string }): { + sections: PaymentProviderConfigurationSheetSection[]; + }; - // Product configuration - getIsProductConfigurable(): boolean; // This is true for almost all payment providers, except for Dev Checkout - getDefaultProductConfiguration(): Partial< - z.infer - >; - getProductConfigurationSchema(): TProductConfigurationSchema; - getProductConfigurationSheet({ projectId }: { projectId: string }): { - sections: PaymentProviderProductEditorSheetSection[]; - }; - getProductKeyProperties(): string[]; - createProductKey(configuration: z.infer): string; - checkIfCorrectlyConfigured( - configuration: z.infer - ): boolean; + // Product configuration + getIsProductConfigurable(): boolean; // This is true for almost all payment providers, except for Dev Checkout + getDefaultProductConfiguration(): Partial< + z.infer + >; + getProductConfigurationSchema(): TProductConfigurationSchema; + getProductConfigurationSheet({ projectId }: { projectId: string }): { + sections: PaymentProviderProductEditorSheetSection[]; + }; + getProductKeyProperties(): string[]; + createProductKey(configuration: z.infer): string; + checkIfCorrectlyConfigured( + configuration: z.infer + ): boolean; - // Configuration is optional for payment providers that don't require configuration - e.g. Dev Checkout - // configuration: PaymentProviderConfiguration< - // TConfiguration, - // TConfigurationSchema - // > | null; - // products: PaymentProviderConfigurationProduct< - // TProductConfiguration, - // TProductConfigurationSchema - // >; + // Configuration is optional for payment providers that don't require configuration - e.g. Dev Checkout + // configuration: PaymentProviderConfiguration< + // TConfiguration, + // TConfigurationSchema + // > | null; + // products: PaymentProviderConfigurationProduct< + // TProductConfiguration, + // TProductConfigurationSchema + // >; - // keyProperties: string[]; - // defaultProductConfiguration: TProductConfiguration; - // productConfigurationSchema: TProductConfigurationSchema; - // createProductEditorSheet: (params: CreateProductEditorSheetParams) => { - // sections: PaymentProviderProductEditorSheetSection[]; - // }; + // keyProperties: string[]; + // defaultProductConfiguration: TProductConfiguration; + // productConfigurationSchema: TProductConfigurationSchema; + // createProductEditorSheet: (params: CreateProductEditorSheetParams) => { + // sections: PaymentProviderProductEditorSheetSection[]; + // }; } diff --git a/apps/web/lib/core/payment-providers/types.ts b/apps/web/lib/core/payment-providers/types.ts index bddc163ec..058634896 100644 --- a/apps/web/lib/core/payment-providers/types.ts +++ b/apps/web/lib/core/payment-providers/types.ts @@ -1,44 +1,44 @@ type PaymentProviderTextInputSection = { - type: "text-input"; - name: string; - label: string; - input: { - type: "text" | "password"; - placeholder?: string; - }; + type: 'text-input'; + name: string; + label: string; + input: { + type: 'text' | 'password'; + placeholder?: string; + }; }; type PaymentProviderCopyTextSection = { - type: "copy-text"; - label: string; - text: string; + type: 'copy-text'; + label: string; + text: string; }; type PaymentProviderP8UploadSection = { - type: "p8-upload"; - name: string; - label: string; - successMessage: string; + type: 'p8-upload'; + name: string; + label: string; + successMessage: string; }; export type CreateConfigurationSheetParams = { - projectId: string; + projectId: string; }; export type PaymentProviderConfigurationSheetSection = { - key: string; + key: string; } & ( - | PaymentProviderTextInputSection - | PaymentProviderCopyTextSection - | PaymentProviderP8UploadSection + | PaymentProviderTextInputSection + | PaymentProviderCopyTextSection + | PaymentProviderP8UploadSection ); export type CreateProductEditorSheetParams = { - productId: string; + productId: string; }; export type PaymentProviderProductEditorSheetSection = { - key: string; + key: string; } & (PaymentProviderTextInputSection | PaymentProviderCopyTextSection); export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; diff --git a/apps/web/lib/core/products/lib.ts b/apps/web/lib/core/products/lib.ts index 50198076c..5872c06ac 100644 --- a/apps/web/lib/core/products/lib.ts +++ b/apps/web/lib/core/products/lib.ts @@ -1,33 +1,33 @@ -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { VoidhashInternalServerError } from "@voidhash/lib/constants"; -import { err, ok, Result } from "neverthrow"; -import { z } from "zod"; +import type { VoidhashInternalServerError } from '@voidhash/lib/constants'; +import { err, ok, type Result } from 'neverthrow'; +import type { z } from 'zod'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; export const createPaymentProviderKey = < - TKey extends ReturnType<(typeof paymentProviders)[number]["getId"]>, - TConfiguration extends z.infer< - ReturnType< - (typeof paymentProviders)[number]["getProductConfigurationSchema"] - > - >, + TKey extends ReturnType<(typeof paymentProviders)[number]['getId']>, + TConfiguration extends z.infer< + ReturnType< + (typeof paymentProviders)[number]['getProductConfigurationSchema'] + > + > >( - paymentProviderId: TKey, - configuration: TConfiguration + paymentProviderId: TKey, + configuration: TConfiguration ): Result => { - const paymentProvider = paymentProviders.find( - (p) => p.getId() === paymentProviderId - ); - if (!paymentProvider) { - return err({ - code: "INTERNAL_SERVER_ERROR", - message: "Payment provider not found", - originalError: new Error("Payment provider not found"), - }); - } - return ok( - paymentProvider - .getProductKeyProperties() - .map((key) => configuration[key]) - .join(":") - ); + const paymentProvider = paymentProviders.find( + (p) => p.getId() === paymentProviderId + ); + if (!paymentProvider) { + return err({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Payment provider not found', + originalError: new Error('Payment provider not found') + }); + } + return ok( + paymentProvider + .getProductKeyProperties() + .map((key) => configuration[key]) + .join(':') + ); }; diff --git a/apps/web/lib/core/projects/permissions.ts b/apps/web/lib/core/projects/permissions.ts index 911c77453..e9e26ee10 100644 --- a/apps/web/lib/core/projects/permissions.ts +++ b/apps/web/lib/core/projects/permissions.ts @@ -1,6 +1,6 @@ export const ProjectPermissions = { - all: "project:all", + all: 'project:all' } as const; export type ProjectPermission = - (typeof ProjectPermissions)[keyof typeof ProjectPermissions]; + (typeof ProjectPermissions)[keyof typeof ProjectPermissions]; diff --git a/apps/web/lib/core/sdk/constants.ts b/apps/web/lib/core/sdk/constants.ts index e7446a7a6..d136b2b0c 100644 --- a/apps/web/lib/core/sdk/constants.ts +++ b/apps/web/lib/core/sdk/constants.ts @@ -1 +1 @@ -export const ANONYMOUS_USER_ID_PREFIX = "vh:anon:"; +export const ANONYMOUS_USER_ID_PREFIX = 'vh:anon:'; diff --git a/apps/web/lib/core/sdk/utils.ts b/apps/web/lib/core/sdk/utils.ts index 34444ef34..a1247d659 100644 --- a/apps/web/lib/core/sdk/utils.ts +++ b/apps/web/lib/core/sdk/utils.ts @@ -1,4 +1,4 @@ -import { ANONYMOUS_USER_ID_PREFIX } from "./constants"; +import { ANONYMOUS_USER_ID_PREFIX } from './constants'; export const isAnonymousId = (id: string) => - id.startsWith(ANONYMOUS_USER_ID_PREFIX); + id.startsWith(ANONYMOUS_USER_ID_PREFIX); diff --git a/apps/web/lib/effect/better-auth.ts b/apps/web/lib/effect/better-auth.ts index c804e5b6e..c33fe1897 100644 --- a/apps/web/lib/effect/better-auth.ts +++ b/apps/web/lib/effect/better-auth.ts @@ -1,51 +1,51 @@ -import { Data, Effect } from "effect"; -import * as schema from "@voidhash/db/schema"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { betterAuth } from "better-auth"; -import { APP_DOMAIN } from "@voidhash/lib/constants"; -import { Db } from "./db"; -import { apiKey, organization } from "better-auth/plugins"; -import { nextCookies } from "better-auth/next-js"; +import * as schema from '@voidhash/db/schema'; +import { APP_DOMAIN } from '@voidhash/lib/constants'; +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { nextCookies } from 'better-auth/next-js'; +import { apiKey, organization } from 'better-auth/plugins'; +import { Data, Effect } from 'effect'; +import { Db } from './db'; -export class BetterAuthError extends Data.TaggedError("BetterAuthError")<{ - readonly cause?: unknown; - readonly message: string; +export class BetterAuthError extends Data.TaggedError('BetterAuthError')<{ + readonly cause?: unknown; + readonly message: string; }> {} -export class BetterAuth extends Effect.Service()("app/BetterAuth", { - dependencies: [Db.Default], - effect: Effect.gen(function* () { - const dbService = yield* Db; - const auth = yield* dbService.use(async (db) => - betterAuth({ - baseURL: APP_DOMAIN, - database: drizzleAdapter(db, { - provider: "mysql", - schema: schema, - }), - socialProviders: { - github: { - clientId: process.env.GITHUB_CLIENT_ID as string, - clientSecret: process.env.GITHUB_CLIENT_SECRET as string, - }, - }, - emailAndPassword: { - enabled: true, - }, - plugins: [organization(), apiKey(), nextCookies()], - }) - ); +export class BetterAuth extends Effect.Service()('app/BetterAuth', { + dependencies: [Db.Default], + effect: Effect.gen(function* () { + const dbService = yield* Db; + const auth = yield* dbService.use(async (db) => + betterAuth({ + baseURL: APP_DOMAIN, + database: drizzleAdapter(db, { + provider: 'mysql', + schema + }), + socialProviders: { + github: { + clientId: process.env.GITHUB_CLIENT_ID as string, + clientSecret: process.env.GITHUB_CLIENT_SECRET as string + } + }, + emailAndPassword: { + enabled: true + }, + plugins: [organization(), apiKey(), nextCookies()] + }) + ); - return { - use: (fn: (client: typeof auth) => Promise) => - Effect.tryPromise({ - try: () => fn(auth), - catch: (error) => - new BetterAuthError({ - message: "Failed to use better-auth", - cause: error, - }), - }), - }; - }), + return { + use: (fn: (client: typeof auth) => Promise) => + Effect.tryPromise({ + try: () => fn(auth), + catch: (error) => + new BetterAuthError({ + message: 'Failed to use better-auth', + cause: error + }) + }) + }; + }) }) {} diff --git a/apps/web/lib/effect/config.ts b/apps/web/lib/effect/config.ts index b659c227a..c8e6b4906 100644 --- a/apps/web/lib/effect/config.ts +++ b/apps/web/lib/effect/config.ts @@ -1,27 +1,25 @@ -import { Effect } from "effect"; -import { env } from "../env"; +import { Effect } from 'effect'; +import { env } from '../env'; -export class Config extends Effect.Service()("app/Config", { - effect: Effect.gen(function* () { - return { - getConfig: Effect.succeed({ - betterAuthSecret: env.BETTER_AUTH_SECRET, - databaseHost: env.DATABASE_HOST, - databasePort: env.DATABASE_PORT, - databaseUsername: env.DATABASE_USERNAME, - databasePassword: env.DATABASE_PASSWORD, - databaseName: env.DATABASE_NAME, - voidhashSecretKey: env.VOIDHASH_SECRET_KEY, - triggerProjectId: env.TRIGGER_PROJECT_ID, - triggerSecretKey: env.TRIGGER_SECRET_KEY, - githubClientId: env.GITHUB_CLIENT_ID, - githubClientSecret: env.GITHUB_CLIENT_SECRET, - polarAccessToken: env.POLAR_ACCESS_TOKEN, - axiomLogsDataset: env.AXIOM_LOGS_DATASET, - axiomToken: env.AXIOM_TOKEN, - axiomLogLevel: env.AXIOM_LOG_LEVEL, - }), - }; - }), - dependencies: [], +export class Config extends Effect.Service()('app/Config', { + effect: Effect.succeed({ + getConfig: Effect.succeed({ + betterAuthSecret: env.BETTER_AUTH_SECRET, + databaseHost: env.DATABASE_HOST, + databasePort: env.DATABASE_PORT, + databaseUsername: env.DATABASE_USERNAME, + databasePassword: env.DATABASE_PASSWORD, + databaseName: env.DATABASE_NAME, + voidhashSecretKey: env.VOIDHASH_SECRET_KEY, + triggerProjectId: env.TRIGGER_PROJECT_ID, + triggerSecretKey: env.TRIGGER_SECRET_KEY, + githubClientId: env.GITHUB_CLIENT_ID, + githubClientSecret: env.GITHUB_CLIENT_SECRET, + polarAccessToken: env.POLAR_ACCESS_TOKEN, + axiomLogsDataset: env.AXIOM_LOGS_DATASET, + axiomToken: env.AXIOM_TOKEN, + axiomLogLevel: env.AXIOM_LOG_LEVEL + }) + }), + dependencies: [] }) {} diff --git a/apps/web/lib/effect/cookies.ts b/apps/web/lib/effect/cookies.ts index b25b2ead0..be35e91f5 100644 --- a/apps/web/lib/effect/cookies.ts +++ b/apps/web/lib/effect/cookies.ts @@ -1,20 +1,20 @@ -import { Context, Data, Effect } from "effect"; +import { Context, Data, type Effect } from 'effect'; -export class CookiesError extends Data.TaggedError("CookiesError")<{ - readonly cause?: unknown; - readonly message: string; +export class CookiesError extends Data.TaggedError('CookiesError')<{ + readonly cause?: unknown; + readonly message: string; }> {} -export class Cookies extends Context.Tag("app/Cookies")< - Cookies, - { - readonly getCookie: ( - name: string - ) => Effect.Effect; - readonly setCookie: ( - name: string, - value: string - ) => Effect.Effect; - readonly deleteCookie: (name: string) => Effect.Effect; - } +export class Cookies extends Context.Tag('app/Cookies')< + Cookies, + { + readonly getCookie: ( + name: string + ) => Effect.Effect; + readonly setCookie: ( + name: string, + value: string + ) => Effect.Effect; + readonly deleteCookie: (name: string) => Effect.Effect; + } >() {} diff --git a/apps/web/lib/effect/db.ts b/apps/web/lib/effect/db.ts deleted file mode 100644 index 0bffda4f2..000000000 --- a/apps/web/lib/effect/db.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Cause, Context, Data, Effect, Exit, Option, Runtime } from "effect"; -import { db, Transaction } from "@voidhash/db"; - -export class DatabaseError extends Data.TaggedError("DatabaseError")<{ - readonly cause?: unknown; - readonly message: string; -}> {} - -type TransactionContextShape = ( - fn: (client: Transaction) => Promise -) => Effect.Effect; -export class TransactionContext extends Context.Tag("TransactionContext")< - TransactionContext, - TransactionContextShape ->() { - public static readonly provide = ( - transaction: TransactionContextShape - ): (( - self: Effect.Effect - ) => Effect.Effect>) => - Effect.provideService(this, transaction); -} - -type Client = typeof db; - -export class Db extends Effect.Service()("app/Db", { - dependencies: [], - effect: Effect.gen(function* () { - const use = Effect.fn((fn: (client: Client) => Promise) => - Effect.tryPromise({ - try: () => fn(db), - catch: (cause) => { - return new DatabaseError({ - message: "Failed to execute transaction", - cause: cause, - }); - }, - }) - ); - - const transaction = Effect.fn("Database.transaction")( - ( - txExecute: (tx: TransactionContextShape) => Effect.Effect - ) => - Effect.runtime().pipe( - Effect.map((runtime) => Runtime.runPromiseExit(runtime)), - Effect.flatMap((runPromiseExit) => - Effect.async((resume) => { - db.transaction(async (tx: Transaction) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const txWrapper = (fn: (client: Transaction) => Promise) => - Effect.tryPromise({ - try: () => fn(tx), - catch: (cause) => - new DatabaseError({ - message: "Failed to execute transaction", - cause: cause, - }), - }); - - const result = await runPromiseExit(txExecute(txWrapper)); - Exit.match(result, { - onSuccess: (value) => { - resume(Effect.succeed(value)); - }, - onFailure: (cause) => { - if (Cause.isFailType(cause)) { - resume(Effect.fail(cause.error)); - } else { - resume(Effect.die(cause)); - } - }, - }); - }).catch((cause) => { - resume( - Effect.fail( - new DatabaseError({ - message: "Failed to execute transaction", - cause: cause, - }) - ) - ); - }); - }) - ) - ) - ); - - type ExecuteFn = ( - fn: (client: Client | Transaction) => Promise - ) => Effect.Effect; - const makeQuery = - ( - queryFn: (execute: ExecuteFn, input: Input) => Effect.Effect - ) => - ( - ...args: [Input] extends [never] ? [] : [input: Input] - ): Effect.Effect => { - const input = args[0] as Input; - return Effect.serviceOption(TransactionContext).pipe( - Effect.map(Option.getOrNull), - Effect.flatMap((txOrNull) => queryFn(txOrNull ?? use, input)) - ); - }; - - return { - use: use, - makeQuery: makeQuery, - transaction: transaction, - }; - }), -}) {} diff --git a/apps/web/lib/effect/db.tsx b/apps/web/lib/effect/db.tsx new file mode 100644 index 000000000..e78bc1b59 --- /dev/null +++ b/apps/web/lib/effect/db.tsx @@ -0,0 +1,111 @@ +import { db, type Transaction } from '@voidhash/db'; +import { Cause, Context, Data, Effect, Exit, Option, Runtime } from 'effect'; + +export class DatabaseError extends Data.TaggedError('DatabaseError')<{ + readonly cause?: unknown; + readonly message: string; +}> {} + +type TransactionContextShape = ( + fn: (client: Transaction) => Promise +) => Effect.Effect; +export class TransactionContext extends Context.Tag('TransactionContext')< + TransactionContext, + TransactionContextShape +>() { + static readonly provide = ( + transaction: TransactionContextShape + ): (( + self: Effect.Effect + ) => Effect.Effect>) => + Effect.provideService(this, transaction); +} + +type Client = typeof db; + +export class Db extends Effect.Service()('app/Db', { + dependencies: [], + effect: Effect.gen(function* () { + const use = Effect.fn((fn: (client: Client) => Promise) => + Effect.tryPromise({ + try: () => fn(db), + catch: (cause) => + new DatabaseError({ + message: 'Failed to execute transaction', + cause + }) + }) + ); + + const transaction = Effect.fn('Database.transaction')( + ( + txExecute: (tx: TransactionContextShape) => Effect.Effect + ) => + Effect.runtime().pipe( + Effect.map((runtime) => Runtime.runPromiseExit(runtime)), + Effect.flatMap((runPromiseExit) => + Effect.async((resume) => { + db.transaction(async (tx: Transaction) => { + // biome-ignore lint/suspicious/noExplicitAny: transaction wrapper + const txWrapper = (fn: (client: Transaction) => Promise) => + Effect.tryPromise({ + try: () => fn(tx), + catch: (cause) => + new DatabaseError({ + message: 'Failed to execute transaction', + cause + }) + }); + + const result = await runPromiseExit(txExecute(txWrapper)); + Exit.match(result, { + onSuccess: (value) => { + resume(Effect.succeed(value)); + }, + onFailure: (cause) => { + if (Cause.isFailType(cause)) { + resume(Effect.fail(cause.error)); + } else { + resume(Effect.die(cause)); + } + } + }); + }).catch((cause) => { + resume( + Effect.fail( + new DatabaseError({ + message: 'Failed to execute transaction', + cause + }) + ) + ); + }); + }) + ) + ) + ); + + type ExecuteFn = ( + fn: (client: Client | Transaction) => Promise + ) => Effect.Effect; + const makeQuery = + ( + queryFn: (execute: ExecuteFn, input: Input) => Effect.Effect + ) => + ( + ...args: [Input] extends [never] ? [] : [input: Input] + ): Effect.Effect => { + const input = args[0] as Input; + return Effect.serviceOption(TransactionContext).pipe( + Effect.map(Option.getOrNull), + Effect.flatMap((txOrNull) => queryFn(txOrNull ?? use, input)) + ); + }; + + return { + use, + makeQuery, + transaction + }; + }) +}) {} diff --git a/apps/web/lib/effect/errors.ts b/apps/web/lib/effect/errors.ts index f76c85e3e..18a856cec 100644 --- a/apps/web/lib/effect/errors.ts +++ b/apps/web/lib/effect/errors.ts @@ -1,18 +1,16 @@ -import { Data } from "effect"; +import { Data } from 'effect'; -export class UnauthorizedError extends Data.TaggedError( - "UnauthorizedError" -)<{ - readonly cause?: unknown; - readonly message: string; +export class UnauthorizedError extends Data.TaggedError('UnauthorizedError')<{ + readonly cause?: unknown; + readonly message: string; }> {} -export class ForbiddenError extends Data.TaggedError("ForbiddenError")<{ - readonly cause?: unknown; - readonly message: string; +export class ForbiddenError extends Data.TaggedError('ForbiddenError')<{ + readonly cause?: unknown; + readonly message: string; }> {} -export class NotFoundError extends Data.TaggedError("NotFoundError")<{ - readonly cause?: unknown; - readonly message: string; +export class NotFoundError extends Data.TaggedError('NotFoundError')<{ + readonly cause?: unknown; + readonly message: string; }> {} diff --git a/apps/web/lib/effect/permissions.ts b/apps/web/lib/effect/permissions.ts index 21d1b0c79..6da1e27dd 100644 --- a/apps/web/lib/effect/permissions.ts +++ b/apps/web/lib/effect/permissions.ts @@ -1,44 +1,44 @@ -import { Effect } from "effect"; -import { OrganizationPermission } from "../core/organizations/permissions"; -import { ProjectPermission } from "../core/projects/permissions"; -import { AuthSession } from "../services/auth.service"; -import { ForbiddenError } from "./errors"; +import { Effect } from 'effect'; +import type { OrganizationPermission } from '../core/organizations/permissions'; +import type { ProjectPermission } from '../core/projects/permissions'; +import { AuthSession } from '../services/auth.service'; +import { ForbiddenError } from './errors'; export const checkProjectPermission = ( - projectId: string, - permission: ProjectPermission, - message: string + projectId: string, + permission: ProjectPermission, + message: string ) => - Effect.gen(function* () { - const session = yield* AuthSession; - const hasPermission = session?.projects.some( - (p) => p.id === projectId && p.permissions.includes(permission) - ); - return yield* processPermissionCheck(hasPermission, message); - }); + Effect.gen(function* () { + const session = yield* AuthSession; + const hasPermission = session?.projects.some( + (p) => p.id === projectId && p.permissions.includes(permission) + ); + return yield* processPermissionCheck(hasPermission, message); + }); export const checkOrganizationPermission = ( - organizationId: string, - permission: OrganizationPermission, - message: string + organizationId: string, + permission: OrganizationPermission, + message: string ) => - Effect.gen(function* () { - const session = yield* AuthSession; - const hasPermission = session?.organizations.some( - (o) => o.id === organizationId && o.permissions.includes(permission) - ); - return yield* processPermissionCheck(hasPermission, message); - }); + Effect.gen(function* () { + const session = yield* AuthSession; + const hasPermission = session?.organizations.some( + (o) => o.id === organizationId && o.permissions.includes(permission) + ); + return yield* processPermissionCheck(hasPermission, message); + }); const processPermissionCheck = (hasPermission: boolean, message: string) => - Effect.gen(function* () { - if (!hasPermission) { - yield* Effect.logWarning(message); - return yield* Effect.fail( - new ForbiddenError({ - message: message, - }) - ); - } - return yield* Effect.succeed(true); - }); + Effect.gen(function* () { + if (!hasPermission) { + yield* Effect.logWarning(message); + return yield* Effect.fail( + new ForbiddenError({ + message + }) + ); + } + return yield* Effect.succeed(true); + }); diff --git a/apps/web/lib/effect/request.ts b/apps/web/lib/effect/request.ts index cdaebe3e4..d6836d1cf 100644 --- a/apps/web/lib/effect/request.ts +++ b/apps/web/lib/effect/request.ts @@ -1,9 +1,11 @@ -import { Context, Effect } from "effect"; +import { Context, type Effect } from 'effect'; -export class Request extends Context.Tag("app/Request")< - Request, - { - readonly getSource: Effect.Effect<"nextjs" | "api-server" | "api-sdk">; - readonly getHeaders: Effect.Effect; - } +export class Request extends Context.Tag('app/Request')< + Request, + { + readonly getSource: () => Effect.Effect< + 'nextjs' | 'api-server' | 'api-sdk' + >; + readonly getHeaders: () => Effect.Effect; + } >() {} diff --git a/apps/web/lib/effect/runtimes/hono.ts b/apps/web/lib/effect/runtimes/hono.ts index feab0dd24..efc2c565c 100644 --- a/apps/web/lib/effect/runtimes/hono.ts +++ b/apps/web/lib/effect/runtimes/hono.ts @@ -1,202 +1,208 @@ import { - Cause, - Context, - Data, - Effect, - Exit, - Layer, - ManagedRuntime, - Option, - pipe, -} from "effect"; -import { Cookies, CookiesError } from "../cookies"; -import { DatabaseError, Db } from "../db"; + Cause, + Context, + Data, + Effect, + Exit, + Layer, + ManagedRuntime, + Option, + pipe +} from 'effect'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; +import { isDynamicServerError } from 'next/dist/client/components/hooks-server-context'; +import { unstable_rethrow } from 'next/navigation'; +import type { z } from 'zod'; +import { type ErrorCode, errorResponse } from '@/lib/api/errors/http'; +import { AppStoreProviderLayer } from '@/lib/payment-providers/app-store/layer'; +import { DevCheckoutService } from '@/lib/payment-providers/dev-checkout/dev-checkout.service'; +import { ApiKeyRepository } from '@/lib/repositories/api-key.repository'; +import { CheckoutSessionRepository } from '@/lib/repositories/checkout-session.repository'; +import { CustomerRepository } from '@/lib/repositories/customer.repository'; +import { OrganizationRepository } from '@/lib/repositories/organization.repository'; +import { PaymentProviderConfigurationRepository } from '@/lib/repositories/payment-provider.repository'; +import { PaymentProviderConfigurationProductRepository } from '@/lib/repositories/payment-provider-configuration-product.repository'; +import { PaywallRepository } from '@/lib/repositories/paywall.repository'; +import { PaywallLocationRepository } from '@/lib/repositories/paywall-location.repository'; +import { PerkRepository } from '@/lib/repositories/perk.repository'; +import { ProductRepository } from '@/lib/repositories/product.repository'; +import { ProductPerkRepository } from '@/lib/repositories/product-perk.repository'; +import { ProjectRepository } from '@/lib/repositories/project.repository'; +import { ApiKeyService } from '@/lib/services/api-key.service'; +import { CustomerService } from '@/lib/services/customer.service'; import { - AuthService, - InvalidPublishableKeyError, - InvalidSecretKeyError, - InvalidSourceError, - MissingAppUserIdError, - MissingProjectIdError, - MissingPublishableKeyError, - MissingSecretKeyError, -} from "../../services/auth.service"; -import { BetterAuth, BetterAuthError } from "../better-auth"; -import { Request } from "../request"; -import { PerkService } from "@/lib/services/perk.service"; -import { PerkRepository } from "@/lib/repositories/perk.repository"; -import { PaywallLocationService } from "@/lib/services/paywall-location.service"; -import { PaywallLocationRepository } from "@/lib/repositories/paywall-location.repository"; -import { PaywallRepository } from "@/lib/repositories/paywall.repository"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { ApiKeyRepository } from "@/lib/repositories/api-key.repository"; -import { ApiKeyService } from "@/lib/services/api-key.service"; -import { CustomerRepository } from "@/lib/repositories/customer.repository"; -import { CustomerService } from "@/lib/services/customer.service"; -import { Context as HonoContextType } from "../../api/hono/app"; -import { deleteCookie, getCookie, setCookie } from "hono/cookie"; -import { ProjectRepository } from "@/lib/repositories/project.repository"; -import { OrganizationRepository } from "@/lib/repositories/organization.repository"; + EnvironmentService, + type InvalidEnvironmentError, + type OrganizationNotFoundInSessionError, + type ProjectNotFoundInSessionError +} from '@/lib/services/environment.service'; +import { OrganizationService } from '@/lib/services/organization.service'; +import { PaymentProviderService } from '@/lib/services/payment-provider.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { PaywallLocationService } from '@/lib/services/paywall-location.service'; +import { PerkService } from '@/lib/services/perk.service'; +import { ProductService } from '@/lib/services/product.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { SdkService } from '@/lib/services/sdk.service'; +import { UserService } from '@/lib/services/user.service'; +import type { Context as HonoContextType } from '../../api/hono/app'; import { - EnvironmentService, - InvalidEnvironmentError, - OrganizationNotFoundInSessionError, - ProjectNotFoundInSessionError, -} from "@/lib/services/environment.service"; -import { ProjectService } from "@/lib/services/project.service"; -import { ProductRepository } from "@/lib/repositories/product.repository"; -import { ProductService } from "@/lib/services/product.service"; -import { SdkService } from "@/lib/services/sdk.service"; -import { PaymentProviderRepository } from "@/lib/repositories/payment-provider.repository"; -import { CheckoutSessionRepository } from "@/lib/repositories/checkout-session.repository"; -import { ForbiddenError, NotFoundError, UnauthorizedError } from "../errors"; -import { MissingEnvironmentError } from "../../services/environment.service"; -import { ErrorCode, errorResponse } from "@/lib/api/errors/http"; -import { z } from "zod"; -import { DevCheckoutService } from "@/lib/payment-providers/dev-checkout/dev-checkout.service"; -import { PaymentProviderConfigurationProductRepository } from "@/lib/repositories/payment-provider-configuration-product.repository"; -import { ProductPerkRepository } from "@/lib/repositories/product-perk.repository"; -import { OrganizationService } from "@/lib/services/organization.service"; -import { PaymentProviderService } from "@/lib/services/payment-provider.service"; -import { UserService } from "@/lib/services/user.service"; -import { HonoRuntimeTag } from "./tags"; -import { isDynamicServerError } from "next/dist/client/components/hooks-server-context"; -import { unstable_rethrow } from "next/navigation"; + AuthService, + type InvalidPublishableKeyError, + type InvalidSecretKeyError, + type InvalidSourceError, + type MissingAppUserIdError, + type MissingProjectIdError, + type MissingPublishableKeyError, + type MissingSecretKeyError +} from '../../services/auth.service'; +import type { MissingEnvironmentError } from '../../services/environment.service'; +import { BetterAuth, type BetterAuthError } from '../better-auth'; +import { Cookies, CookiesError } from '../cookies'; +import { type DatabaseError, Db } from '../db'; +import type { + ForbiddenError, + NotFoundError, + UnauthorizedError +} from '../errors'; +import { Request } from '../request'; +import { HonoRuntimeTag } from './tags'; -export class HonoContext extends Context.Tag("app/HonoContext")< - HonoContext, - HonoContextType +export class HonoContext extends Context.Tag('app/HonoContext')< + HonoContext, + HonoContextType >() {} const HonoRuntimeTagLive = Layer.succeed( - HonoRuntimeTag, - HonoRuntimeTag.of("hono") + HonoRuntimeTag, + HonoRuntimeTag.of('hono') ); const CookiesLive = Layer.effect( - Cookies, - Effect.gen(function* () { - return { - getCookie: (name) => - Effect.gen(function* () { - const honoContext = yield* Effect.serviceOption(HonoContext); - if (Option.isNone(honoContext)) { - return yield* Effect.fail( - new CookiesError({ - message: "Hono context not found", - }) - ); - } - return getCookie(honoContext.value, name) ?? null; - }), - setCookie: (name, value) => - Effect.gen(function* () { - console.log("setCookie", name, value); - const honoContext = yield* Effect.serviceOption(HonoContext); - if (Option.isNone(honoContext)) { - return yield* Effect.fail( - new CookiesError({ - message: "Hono context not found", - }) - ); - } - setCookie(honoContext.value, name, value); - return; - }), - deleteCookie: (name) => - Effect.gen(function* () { - const honoContext = yield* Effect.serviceOption(HonoContext); - if (Option.isNone(honoContext)) { - return yield* Effect.fail( - new CookiesError({ - message: "Hono context not found", - }) - ); - } - deleteCookie(honoContext.value, name); - return; - }), - }; - }) + Cookies, + Effect.gen(function* () { + return { + getCookie: (name) => + Effect.gen(function* () { + const honoContext = yield* Effect.serviceOption(HonoContext); + if (Option.isNone(honoContext)) { + return yield* Effect.fail( + new CookiesError({ + message: 'Hono context not found' + }) + ); + } + return getCookie(honoContext.value, name) ?? null; + }), + setCookie: (name, value) => + Effect.gen(function* () { + const honoContext = yield* Effect.serviceOption(HonoContext); + if (Option.isNone(honoContext)) { + return yield* Effect.fail( + new CookiesError({ + message: 'Hono context not found' + }) + ); + } + setCookie(honoContext.value, name, value); + return; + }), + deleteCookie: (name) => + Effect.gen(function* () { + const honoContext = yield* Effect.serviceOption(HonoContext); + if (Option.isNone(honoContext)) { + return yield* Effect.fail( + new CookiesError({ + message: 'Hono context not found' + }) + ); + } + deleteCookie(honoContext.value, name); + return; + }) + }; + }) ); const RequestLive = Layer.effect( - Request, - Effect.gen(function* () { - const c = yield* HonoContext; - const sdkPathPrefixes = ["/api/v1/sdk", "/v1/sdk"]; + Request, + Effect.gen(function* () { + const c = yield* HonoContext; + const sdkPathPrefixes = ['/api/v1/sdk', '/v1/sdk']; - const isSdkPathname = sdkPathPrefixes.some((prefix) => - c.req.path.startsWith(prefix) - ); + const isSdkPathname = sdkPathPrefixes.some((prefix) => + c.req.path.startsWith(prefix) + ); - const source = isSdkPathname ? "api-sdk" : "api-server"; + const source = isSdkPathname ? 'api-sdk' : 'api-server'; - return { - getSource: Effect.succeed(source as "nextjs" | "api-server" | "api-sdk"), - getHeaders: Effect.promise(async () => c.req.raw.headers), - }; - }) + return { + getSource: () => + Effect.succeed(source as 'nextjs' | 'api-server' | 'api-sdk'), + getHeaders: () => Effect.promise(async () => c.req.raw.headers) + }; + }) ); const DbLive = Db.Default; const RuntimeLayer = (context: HonoContextType) => { - const CoreLayer = pipe( - AuthService.Default, - Layer.provideMerge(BetterAuth.Default), - Layer.provideMerge(DbLive), - Layer.provideMerge(CookiesLive), - Layer.provideMerge(RequestLive), - Layer.provideMerge(Layer.succeed(HonoContext, context)), - Layer.provideMerge(HonoRuntimeTagLive) - ); + const CoreLayer = pipe( + AuthService.Default, + Layer.provideMerge(BetterAuth.Default), + Layer.provideMerge(DbLive), + Layer.provideMerge(CookiesLive), + Layer.provideMerge(RequestLive), + Layer.provideMerge(Layer.succeed(HonoContext, context)), + Layer.provideMerge(HonoRuntimeTagLive) + ); - const RepositoryLayer = pipe( - ApiKeyRepository.Default, - Layer.provideMerge(CustomerRepository.Default), - Layer.provideMerge(CheckoutSessionRepository.Default), - Layer.provideMerge(OrganizationRepository.Default), - Layer.provideMerge(PaymentProviderConfigurationProductRepository.Default), - Layer.provideMerge(PaymentProviderRepository.Default), - Layer.provideMerge(PaywallLocationRepository.Default), - Layer.provideMerge(PaywallRepository.Default), - Layer.provideMerge(PerkRepository.Default), - Layer.provideMerge(ProductPerkRepository.Default), - Layer.provideMerge(ProductRepository.Default), - Layer.provideMerge(ProjectRepository.Default) - ); + const RepositoryLayer = pipe( + ApiKeyRepository.Default, + Layer.provideMerge(CustomerRepository.Default), + Layer.provideMerge(CheckoutSessionRepository.Default), + Layer.provideMerge(OrganizationRepository.Default), + Layer.provideMerge(PaymentProviderConfigurationProductRepository.Default), + Layer.provideMerge(PaymentProviderConfigurationRepository.Default), + Layer.provideMerge(PaywallLocationRepository.Default), + Layer.provideMerge(PaywallRepository.Default), + Layer.provideMerge(PerkRepository.Default), + Layer.provideMerge(ProductPerkRepository.Default), + Layer.provideMerge(ProductRepository.Default), + Layer.provideMerge(ProjectRepository.Default) + ); - const ServiceLayer = pipe( - ApiKeyService.Default, - Layer.provideMerge(CustomerService.Default), - Layer.provideMerge(EnvironmentService.Default), - Layer.provideMerge(OrganizationService.Default), - Layer.provideMerge(PaymentProviderService.Default), - Layer.provideMerge(PaywallLocationService.Default), - Layer.provideMerge(PaywallService.Default), - Layer.provideMerge(PerkService.Default), - Layer.provideMerge(ProductService.Default), - Layer.provideMerge(ProjectService.Default), - Layer.provideMerge(SdkService.Default), - Layer.provideMerge(UserService.Default), - Layer.provideMerge(DevCheckoutService.Default) - ); + const ServiceLayer = pipe( + ApiKeyService.Default, + Layer.provideMerge(CustomerService.Default), + Layer.provideMerge(EnvironmentService.Default), + Layer.provideMerge(OrganizationService.Default), + Layer.provideMerge(PaymentProviderService.Default), + Layer.provideMerge(PaywallLocationService.Default), + Layer.provideMerge(PaywallService.Default), + Layer.provideMerge(PerkService.Default), + Layer.provideMerge(ProductService.Default), + Layer.provideMerge(ProjectService.Default), + Layer.provideMerge(SdkService.Default), + Layer.provideMerge(UserService.Default), + Layer.provideMerge(DevCheckoutService.Default) + ); - return pipe( - ServiceLayer, - Layer.provideMerge(RepositoryLayer), - Layer.provideMerge(CoreLayer) - ); + return pipe( + AppStoreProviderLayer, + Layer.provideMerge(ServiceLayer), + Layer.provideMerge(RepositoryLayer), + Layer.provideMerge(CoreLayer) + ); }; export const createHonoRuntime = (context: HonoContextType) => - ManagedRuntime.make(RuntimeLayer(context)); + ManagedRuntime.make(RuntimeLayer(context)); -export class HonoErrorResponse extends Data.TaggedError("HonoErrorResponse")<{ - code: z.infer; - message: string; - originalError?: Error; +export class HonoErrorResponse extends Data.TaggedError('HonoErrorResponse')<{ + code: z.infer; + message: string; + originalError?: Error; }> {} // export const createEffectHandler = (context: HonoContextType) => (effect: Effect.Effect): => { @@ -212,226 +218,230 @@ export class HonoErrorResponse extends Data.TaggedError("HonoErrorResponse")<{ type GenericErrors = NotFoundError | ForbiddenError | UnauthorizedError; type SystemErrors = - | CookiesError - | DatabaseError - | BetterAuthError - | InvalidSourceError; + | CookiesError + | DatabaseError + | BetterAuthError + | InvalidSourceError; type AcceptableErrorTypes = - | HonoErrorResponse - | GenericErrors - | SystemErrors - | MissingSecretKeyError - | MissingPublishableKeyError - | InvalidSecretKeyError - | InvalidPublishableKeyError - | MissingAppUserIdError - | MissingEnvironmentError - | MissingProjectIdError - | ProjectNotFoundInSessionError - | OrganizationNotFoundInSessionError - | InvalidEnvironmentError; + | HonoErrorResponse + | GenericErrors + | SystemErrors + | MissingSecretKeyError + | MissingPublishableKeyError + | InvalidSecretKeyError + | InvalidPublishableKeyError + | MissingAppUserIdError + | MissingEnvironmentError + | MissingProjectIdError + | ProjectNotFoundInSessionError + | OrganizationNotFoundInSessionError + | InvalidEnvironmentError; const handleGlobalErrors = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - effect: Effect.Effect - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: should be ok + effect: Effect.Effect + // biome-ignore lint/suspicious/noExplicitAny: should be ok ): Effect.Effect => { - return pipe( - effect, - Effect.catchTags({ - NotFoundError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "NOT_FOUND", - message: error.message, - originalError: error, - }) - ), - ForbiddenError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "FORBIDDEN", - message: error.message, - originalError: error, - }) - ), - UnauthorizedError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "UNAUTHORIZED", - message: error.message, - originalError: error, - }) - ), - CookiesError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - originalError: error, - }) - ), - DatabaseError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - originalError: error, - }) - ), - BetterAuthError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - originalError: error, - }) - ), - InvalidSourceError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - originalError: error, - }) - ), - MissingEnvironmentError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - originalError: error, - }) - ), - MissingSecretKeyError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "UNAUTHORIZED", - message: error.message, - originalError: error, - }) - ), - MissingPublishableKeyError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "UNAUTHORIZED", - message: error.message, - originalError: error, - }) - ), - InvalidSecretKeyError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "UNAUTHORIZED", - message: error.message, - originalError: error, - }) - ), - InvalidPublishableKeyError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "UNAUTHORIZED", - message: error.message, - originalError: error, - }) - ), - MissingAppUserIdError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "UNAUTHORIZED", - message: error.message, - originalError: error, - }) - ), - MissingProjectIdError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - originalError: error, - }) - ), - ProjectNotFoundInSessionError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - originalError: error, - }) - ), - OrganizationNotFoundInSessionError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - originalError: error, - }) - ), - InvalidEnvironmentError: (error) => - Effect.fail( - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - originalError: error, - }) - ), - }) - ); + return pipe( + effect, + Effect.catchTags({ + NotFoundError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'NOT_FOUND', + message: error.message, + originalError: error + }) + ), + ForbiddenError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'FORBIDDEN', + message: error.message, + originalError: error + }) + ), + UnauthorizedError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'UNAUTHORIZED', + message: error.message, + originalError: error + }) + ), + CookiesError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + originalError: error + }) + ), + DatabaseError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + originalError: error + }) + ), + BetterAuthError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + originalError: error + }) + ), + InvalidSourceError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + originalError: error + }) + ), + MissingEnvironmentError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + originalError: error + }) + ), + MissingSecretKeyError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'UNAUTHORIZED', + message: error.message, + originalError: error + }) + ), + MissingPublishableKeyError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'UNAUTHORIZED', + message: error.message, + originalError: error + }) + ), + InvalidSecretKeyError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'UNAUTHORIZED', + message: error.message, + originalError: error + }) + ), + InvalidPublishableKeyError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'UNAUTHORIZED', + message: error.message, + originalError: error + }) + ), + MissingAppUserIdError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'UNAUTHORIZED', + message: error.message, + originalError: error + }) + ), + MissingProjectIdError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + originalError: error + }) + ), + ProjectNotFoundInSessionError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + originalError: error + }) + ), + OrganizationNotFoundInSessionError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + originalError: error + }) + ), + InvalidEnvironmentError: (error) => + Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + originalError: error + }) + ) + }) + ); }; const toHonoErrorResponse = (c: HonoContextType, error: HonoErrorResponse) => { - return errorResponse(c, error.code, error.message); + return errorResponse(c, error.code, error.message); }; type AvailableServices = Layer.Layer.Success>; export const createEffectHandler = - (context: HonoContextType) => - async ( - effect: Effect.Effect - ) => { - const runtime = createHonoRuntime(context); - const result = await runtime.runPromiseExit( - pipe( - effect, - handleGlobalErrors, - Effect.catchTags({ - HonoErrorResponse: (error) => - Effect.succeed(toHonoErrorResponse(context, error)), - }), - Effect.catchAll((error) => { - return Effect.succeed( - toHonoErrorResponse( - context, - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: "Internal server error", - originalError: error, - }) - ) - ); - }) - ) - ); + (context: HonoContextType) => + async ( + effect: Effect.Effect + ) => { + const runtime = createHonoRuntime(context); + const result = await runtime.runPromiseExit( + pipe( + effect, + handleGlobalErrors, + // biome-ignore lint/suspicious/noExplicitAny: required for the instanceof check + Effect.catchAll((error: any) => { + const honoErrorResponse = + error instanceof HonoErrorResponse + ? error + : new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal server error', + originalError: error + }); - return Exit.match(result, { - onSuccess: (value) => value, - onFailure: (error) => { - if (Cause.isDie(error)) { - const defects = Cause.defects(error); - for (const defect of defects) { - if (isDynamicServerError(defect)) { - unstable_rethrow(defect); - } - } - } - return toHonoErrorResponse( - context, - new HonoErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: "Internal server error", - }) - ); - }, - }); - }; + context.get('logger').error('HonoErrorResponse', { + error: error.message, + cause: error.originalError + }); + + return Effect.succeed( + toHonoErrorResponse(context, honoErrorResponse) + ); + }) + ) + ); + + return Exit.match(result, { + onSuccess: (value) => value, + onFailure: (error) => { + if (Cause.isDie(error)) { + const defects = Cause.defects(error); + for (const defect of defects) { + if (isDynamicServerError(defect)) { + unstable_rethrow(defect); + } + } + } + return toHonoErrorResponse( + context, + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal server error' + }) + ); + } + }); + }; diff --git a/apps/web/lib/effect/runtimes/integration-test.ts b/apps/web/lib/effect/runtimes/integration-test.ts index d1c1717a1..e458c3b42 100644 --- a/apps/web/lib/effect/runtimes/integration-test.ts +++ b/apps/web/lib/effect/runtimes/integration-test.ts @@ -1,153 +1,141 @@ -import { Context, Data, Effect, Layer, ManagedRuntime, pipe } from "effect"; -import { Cookies } from "../cookies"; -import { Db } from "../db"; -import { AuthService } from "../../services/auth.service"; -import { BetterAuth } from "../better-auth"; -import { PerkService } from "@/lib/services/perk.service"; -import { PerkRepository } from "@/lib/repositories/perk.repository"; -import { PaywallLocationService } from "@/lib/services/paywall-location.service"; -import { PaywallLocationRepository } from "@/lib/repositories/paywall-location.repository"; -import { PaywallRepository } from "@/lib/repositories/paywall.repository"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { ApiKeyRepository } from "@/lib/repositories/api-key.repository"; -import { ApiKeyService } from "@/lib/services/api-key.service"; -import { CustomerRepository } from "@/lib/repositories/customer.repository"; -import { CustomerService } from "@/lib/services/customer.service"; -import { Context as HonoContextType } from "../../api/hono/app"; -import { ProjectRepository } from "@/lib/repositories/project.repository"; -import { OrganizationRepository } from "@/lib/repositories/organization.repository"; -import { EnvironmentService } from "@/lib/services/environment.service"; -import { ProjectService } from "@/lib/services/project.service"; -import { ProductRepository } from "@/lib/repositories/product.repository"; -import { ProductService } from "@/lib/services/product.service"; -import { SdkService } from "@/lib/services/sdk.service"; -import { PaymentProviderRepository } from "@/lib/repositories/payment-provider.repository"; -import { CheckoutSessionRepository } from "@/lib/repositories/checkout-session.repository"; -import { ErrorCode } from "@/lib/api/errors/http"; -import { z } from "zod"; -import { DevCheckoutService } from "@/lib/payment-providers/dev-checkout/dev-checkout.service"; -import { PaymentProviderConfigurationProductRepository } from "@/lib/repositories/payment-provider-configuration-product.repository"; -import { ProductPerkRepository } from "@/lib/repositories/product-perk.repository"; -import { OrganizationService } from "@/lib/services/organization.service"; -import { PaymentProviderService } from "@/lib/services/payment-provider.service"; -import { UserService } from "@/lib/services/user.service"; -import { HonoRuntimeTag, NextjsRuntimeTag } from "./tags"; -import { Request } from "../request"; - -export class HonoContext extends Context.Tag("app/HonoContext")< - HonoContext, - HonoContextType +import { Context, Data, Effect, Layer, ManagedRuntime, pipe } from 'effect'; +import type { z } from 'zod'; +import type { ErrorCode } from '@/lib/api/errors/http'; +import { DevCheckoutService } from '@/lib/payment-providers/dev-checkout/dev-checkout.service'; +import { ApiKeyRepository } from '@/lib/repositories/api-key.repository'; +import { CheckoutSessionRepository } from '@/lib/repositories/checkout-session.repository'; +import { CustomerRepository } from '@/lib/repositories/customer.repository'; +import { OrganizationRepository } from '@/lib/repositories/organization.repository'; +import { PaymentProviderConfigurationRepository } from '@/lib/repositories/payment-provider.repository'; +import { PaymentProviderConfigurationProductRepository } from '@/lib/repositories/payment-provider-configuration-product.repository'; +import { PaywallRepository } from '@/lib/repositories/paywall.repository'; +import { PaywallLocationRepository } from '@/lib/repositories/paywall-location.repository'; +import { PerkRepository } from '@/lib/repositories/perk.repository'; +import { ProductRepository } from '@/lib/repositories/product.repository'; +import { ProductPerkRepository } from '@/lib/repositories/product-perk.repository'; +import { ProjectRepository } from '@/lib/repositories/project.repository'; +import { ApiKeyService } from '@/lib/services/api-key.service'; +import { CustomerService } from '@/lib/services/customer.service'; +import { EnvironmentService } from '@/lib/services/environment.service'; +import { OrganizationService } from '@/lib/services/organization.service'; +import { PaymentProviderService } from '@/lib/services/payment-provider.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { PaywallLocationService } from '@/lib/services/paywall-location.service'; +import { PerkService } from '@/lib/services/perk.service'; +import { ProductService } from '@/lib/services/product.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { SdkService } from '@/lib/services/sdk.service'; +import { UserService } from '@/lib/services/user.service'; +import type { Context as HonoContextType } from '../../api/hono/app'; +import { AuthService } from '../../services/auth.service'; +import { BetterAuth } from '../better-auth'; +import { Cookies } from '../cookies'; +import { Db } from '../db'; +import { Request } from '../request'; +import { HonoRuntimeTag, NextjsRuntimeTag } from './tags'; + +export class HonoContext extends Context.Tag('app/HonoContext')< + HonoContext, + HonoContextType >() {} -type RuntimeType = "nexjts" | "hono"; +type RuntimeType = 'nexjts' | 'hono'; const HonoRuntimeTagLive = Layer.succeed( - HonoRuntimeTag, - HonoRuntimeTag.of("hono"), + HonoRuntimeTag, + HonoRuntimeTag.of('hono') ); const NextjsRuntimeTagLive = Layer.succeed( - NextjsRuntimeTag, - NextjsRuntimeTag.of("nextjs"), + NextjsRuntimeTag, + NextjsRuntimeTag.of('nextjs') ); const CookiesLive = Layer.effect( - Cookies, - Effect.gen(function* () { - const mockCookies = new Map(); - - return { - getCookie: (name) => - Effect.gen(function* () { - return mockCookies.get(name) ?? null; - }), - setCookie: (name, value) => - Effect.gen(function* () { - mockCookies.set(name, value); - return; - }), - deleteCookie: (name) => - Effect.gen(function* () { - mockCookies.delete(name); - return; - }), - }; - }), + Cookies, + Effect.gen(function* () { + const mockCookies = new Map(); + + return { + getCookie: (name) => Effect.succeed(mockCookies.get(name) ?? null), + setCookie: (name, value) => Effect.succeed(mockCookies.set(name, value)), + deleteCookie: (name) => Effect.succeed(mockCookies.delete(name)) + }; + }) ); const RequestLive = Layer.effect( - Request, - Effect.gen(function* () { - return { - getSource: Effect.succeed( - "api-server" as "nextjs" | "api-server" | "api-sdk", - ), - getHeaders: Effect.succeed(new Headers()), - }; - }), + Request, + Effect.gen(function* () { + return { + getSource: () => + Effect.succeed('api-server' as 'nextjs' | 'api-server' | 'api-sdk'), + getHeaders: () => Effect.succeed(new Headers()) + }; + }) ); const DbLive = Db.Default; const RuntimeLayer = (type: RuntimeType) => { - const tag = - type === "hono" - ? Layer.provideMerge(HonoRuntimeTagLive) - : Layer.provideMerge(NextjsRuntimeTagLive); - const CoreLayer = pipe( - AuthService.Default, - Layer.provideMerge(BetterAuth.Default), - Layer.provideMerge(DbLive), - Layer.provideMerge(CookiesLive), - Layer.provideMerge(RequestLive), - tag, - ); - - const RepositoryLayer = pipe( - ApiKeyRepository.Default, - Layer.provideMerge(CustomerRepository.Default), - Layer.provideMerge(CheckoutSessionRepository.Default), - Layer.provideMerge(OrganizationRepository.Default), - Layer.provideMerge(PaymentProviderConfigurationProductRepository.Default), - Layer.provideMerge(PaymentProviderRepository.Default), - Layer.provideMerge(PaywallLocationRepository.Default), - Layer.provideMerge(PaywallRepository.Default), - Layer.provideMerge(PerkRepository.Default), - Layer.provideMerge(ProductPerkRepository.Default), - Layer.provideMerge(ProductRepository.Default), - Layer.provideMerge(ProjectRepository.Default), - ); - - const ServiceLayer = pipe( - ApiKeyService.Default, - Layer.provideMerge(CustomerService.Default), - Layer.provideMerge(EnvironmentService.Default), - Layer.provideMerge(OrganizationService.Default), - Layer.provideMerge(PaymentProviderService.Default), - Layer.provideMerge(PaywallLocationService.Default), - Layer.provideMerge(PaywallService.Default), - Layer.provideMerge(PerkService.Default), - Layer.provideMerge(ProductService.Default), - Layer.provideMerge(ProjectService.Default), - Layer.provideMerge(SdkService.Default), - Layer.provideMerge(UserService.Default), - Layer.provideMerge(DevCheckoutService.Default), - ); - - return pipe( - ServiceLayer, - Layer.provideMerge(RepositoryLayer), - Layer.provideMerge(CoreLayer), - ); + const tag = + type === 'hono' + ? Layer.provideMerge(HonoRuntimeTagLive) + : Layer.provideMerge(NextjsRuntimeTagLive); + const CoreLayer = pipe( + AuthService.Default, + Layer.provideMerge(BetterAuth.Default), + Layer.provideMerge(DbLive), + Layer.provideMerge(CookiesLive), + Layer.provideMerge(RequestLive), + tag + ); + + const RepositoryLayer = pipe( + ApiKeyRepository.Default, + Layer.provideMerge(CustomerRepository.Default), + Layer.provideMerge(CheckoutSessionRepository.Default), + Layer.provideMerge(OrganizationRepository.Default), + Layer.provideMerge(PaymentProviderConfigurationProductRepository.Default), + Layer.provideMerge(PaymentProviderConfigurationRepository.Default), + Layer.provideMerge(PaywallLocationRepository.Default), + Layer.provideMerge(PaywallRepository.Default), + Layer.provideMerge(PerkRepository.Default), + Layer.provideMerge(ProductPerkRepository.Default), + Layer.provideMerge(ProductRepository.Default), + Layer.provideMerge(ProjectRepository.Default) + ); + + const ServiceLayer = pipe( + ApiKeyService.Default, + Layer.provideMerge(CustomerService.Default), + Layer.provideMerge(EnvironmentService.Default), + Layer.provideMerge(OrganizationService.Default), + Layer.provideMerge(PaymentProviderService.Default), + Layer.provideMerge(PaywallLocationService.Default), + Layer.provideMerge(PaywallService.Default), + Layer.provideMerge(PerkService.Default), + Layer.provideMerge(ProductService.Default), + Layer.provideMerge(ProjectService.Default), + Layer.provideMerge(SdkService.Default), + Layer.provideMerge(UserService.Default), + Layer.provideMerge(DevCheckoutService.Default) + ); + + return pipe( + ServiceLayer, + Layer.provideMerge(RepositoryLayer), + Layer.provideMerge(CoreLayer) + ); }; export const createIntegrationTestRuntime = (type: RuntimeType) => - ManagedRuntime.make(RuntimeLayer(type)); + ManagedRuntime.make(RuntimeLayer(type)); -export class HonoErrorResponse extends Data.TaggedError("HonoErrorResponse")<{ - code: z.infer; - message: string; - originalError?: Error; +export class HonoErrorResponse extends Data.TaggedError('HonoErrorResponse')<{ + code: z.infer; + message: string; + originalError?: Error; }> {} // export const createEffectHandler = (context: HonoContextType) => (effect: Effect.Effect): => { @@ -164,11 +152,11 @@ export class HonoErrorResponse extends Data.TaggedError("HonoErrorResponse")<{ type AvailableServices = Layer.Layer.Success>; export const createIntegrationTestRunner = - (type: RuntimeType) => - async ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - effect: Effect.Effect, - ) => { - const runtime = createIntegrationTestRuntime(type); - return await runtime.runPromiseExit(pipe(effect)); - }; + (type: RuntimeType) => + async ( + // biome-ignore lint/suspicious/noExplicitAny: is ok + effect: Effect.Effect + ) => { + const runtime = createIntegrationTestRuntime(type); + return await runtime.runPromiseExit(pipe(effect)); + }; diff --git a/apps/web/lib/effect/runtimes/nextjs.ts b/apps/web/lib/effect/runtimes/nextjs.ts index 6667acd60..44a6701fc 100644 --- a/apps/web/lib/effect/runtimes/nextjs.ts +++ b/apps/web/lib/effect/runtimes/nextjs.ts @@ -1,167 +1,170 @@ import { - Cause, - Effect, - Exit, - Layer, - ManagedRuntime, - pipe, - Schema, -} from "effect"; -import { Cookies, CookiesError } from "../cookies"; -import { cookies, headers } from "next/headers"; -import { DatabaseError, Db } from "../db"; + Cause, + Effect, + Exit, + Layer, + ManagedRuntime, + pipe, + Schema +} from 'effect'; +import { err, ok, type Result } from 'neverthrow'; +import { isDynamicServerError } from 'next/dist/client/components/hooks-server-context'; +import { cookies, headers } from 'next/headers'; +import { unstable_rethrow } from 'next/navigation'; +import { DevCheckoutService } from '@/lib/payment-providers/dev-checkout/dev-checkout.service'; +import { ApiKeyRepository } from '@/lib/repositories/api-key.repository'; +import { CheckoutSessionRepository } from '@/lib/repositories/checkout-session.repository'; +import { CustomerRepository } from '@/lib/repositories/customer.repository'; +import { OrganizationRepository } from '@/lib/repositories/organization.repository'; +import { PaymentProviderConfigurationRepository } from '@/lib/repositories/payment-provider.repository'; +import { PaymentProviderConfigurationProductRepository } from '@/lib/repositories/payment-provider-configuration-product.repository'; +import { PaywallRepository } from '@/lib/repositories/paywall.repository'; +import { PaywallLocationRepository } from '@/lib/repositories/paywall-location.repository'; +import { PerkRepository } from '@/lib/repositories/perk.repository'; +import { ProductRepository } from '@/lib/repositories/product.repository'; +import { ProductPerkRepository } from '@/lib/repositories/product-perk.repository'; +import { ProjectRepository } from '@/lib/repositories/project.repository'; +import { ApiKeyService } from '@/lib/services/api-key.service'; +import { CustomerService } from '@/lib/services/customer.service'; import { - AuthService, - InvalidPublishableKeyError, - InvalidSecretKeyError, - InvalidSourceError, - MissingAppUserIdError, - MissingProjectIdError, - MissingPublishableKeyError, - MissingSecretKeyError, -} from "../../services/auth.service"; -import { BetterAuth, BetterAuthError } from "../better-auth"; -import { Request } from "../request"; -import { PerkService } from "@/lib/services/perk.service"; -import { err, ok, Result } from "neverthrow"; -import { PerkRepository } from "@/lib/repositories/perk.repository"; -import { PaywallLocationService } from "@/lib/services/paywall-location.service"; -import { PaywallLocationRepository } from "@/lib/repositories/paywall-location.repository"; -import { PaywallRepository } from "@/lib/repositories/paywall.repository"; -import { PaywallService } from "@/lib/services/paywall.service"; -import { ApiKeyRepository } from "@/lib/repositories/api-key.repository"; -import { ApiKeyService } from "@/lib/services/api-key.service"; -import { CustomerRepository } from "@/lib/repositories/customer.repository"; -import { CustomerService } from "@/lib/services/customer.service"; + type EnvironmentCookieNotFoundError, + EnvironmentService, + type InvalidEnvironmentError, + type OrganizationNotFoundInSessionError, + type ProjectNotFoundInSessionError +} from '@/lib/services/environment.service'; +import { OrganizationService } from '@/lib/services/organization.service'; +import { PaymentProviderService } from '@/lib/services/payment-provider.service'; +import { PaywallService } from '@/lib/services/paywall.service'; +import { PaywallLocationService } from '@/lib/services/paywall-location.service'; +import { PerkService } from '@/lib/services/perk.service'; +import { ProductService } from '@/lib/services/product.service'; +import { ProjectService } from '@/lib/services/project.service'; +import { SdkService } from '@/lib/services/sdk.service'; +import { UserService } from '@/lib/services/user.service'; import { - EnvironmentCookieNotFoundError, - EnvironmentService, - InvalidEnvironmentError, - OrganizationNotFoundInSessionError, - ProjectNotFoundInSessionError, -} from "@/lib/services/environment.service"; -import { OrganizationRepository } from "@/lib/repositories/organization.repository"; -import { ProjectRepository } from "@/lib/repositories/project.repository"; -import { ProjectService } from "@/lib/services/project.service"; -import { ProductRepository } from "@/lib/repositories/product.repository"; -import { ProductService } from "@/lib/services/product.service"; -import { PaymentProviderRepository } from "@/lib/repositories/payment-provider.repository"; -import { CheckoutSessionRepository } from "@/lib/repositories/checkout-session.repository"; -import { ForbiddenError, NotFoundError, UnauthorizedError } from "../errors"; -import { MissingEnvironmentError } from "../../services/environment.service"; -import { UserService } from "@/lib/services/user.service"; -import { OrganizationService } from "@/lib/services/organization.service"; -import { PaymentProviderService } from "@/lib/services/payment-provider.service"; -import { DevCheckoutService } from "@/lib/payment-providers/dev-checkout/dev-checkout.service"; -import { isDynamicServerError } from "next/dist/client/components/hooks-server-context"; -import { unstable_rethrow } from "next/navigation"; -import { PaymentProviderConfigurationProductRepository } from "@/lib/repositories/payment-provider-configuration-product.repository"; -import { SdkService } from "@/lib/services/sdk.service"; -import { ProductPerkRepository } from "@/lib/repositories/product-perk.repository"; -import { NextjsRuntimeTag } from "./tags"; - + AuthService, + type InvalidPublishableKeyError, + type InvalidSecretKeyError, + type InvalidSourceError, + type MissingAppUserIdError, + type MissingProjectIdError, + type MissingPublishableKeyError, + type MissingSecretKeyError +} from '../../services/auth.service'; +import type { MissingEnvironmentError } from '../../services/environment.service'; +import { BetterAuth, type BetterAuthError } from '../better-auth'; +import { Cookies, CookiesError } from '../cookies'; +import { type DatabaseError, Db } from '../db'; +import type { + ForbiddenError, + NotFoundError, + UnauthorizedError +} from '../errors'; +import { Request } from '../request'; +import { NextjsRuntimeTag } from './tags'; const NextjsRuntimeTagLive = Layer.succeed( - NextjsRuntimeTag, - NextjsRuntimeTag.of("nextjs") + NextjsRuntimeTag, + NextjsRuntimeTag.of('nextjs') ); const CookiesLive = Layer.succeed( - Cookies, - Cookies.of({ - getCookie: (name) => - Effect.tryPromise({ - try: async () => (await cookies()).get(name)?.value ?? null, - catch: (error) => - new CookiesError({ message: "Failed to get cookie", cause: error }), - }), - setCookie: (name, value) => - Effect.tryPromise({ - try: async () => (await cookies()).set(name, value), - catch: (error) => - new CookiesError({ message: "Failed to set cookie", cause: error }), - }), - deleteCookie: (name) => - Effect.tryPromise({ - try: async () => (await cookies()).delete(name), - catch: (error) => - new CookiesError({ - message: "Failed to delete cookie", - cause: error, - }), - }), - }) + Cookies, + Cookies.of({ + getCookie: (name) => + Effect.tryPromise({ + try: async () => (await cookies()).get(name)?.value ?? null, + catch: (error) => + new CookiesError({ message: 'Failed to get cookie', cause: error }) + }), + setCookie: (name, value) => + Effect.tryPromise({ + try: async () => (await cookies()).set(name, value), + catch: (error) => + new CookiesError({ message: 'Failed to set cookie', cause: error }) + }), + deleteCookie: (name) => + Effect.tryPromise({ + try: async () => (await cookies()).delete(name), + catch: (error) => + new CookiesError({ + message: 'Failed to delete cookie', + cause: error + }) + }) + }) ); const RequestLive = Layer.succeed( - Request, - Request.of({ - getSource: Effect.succeed("nextjs"), - getHeaders: Effect.promise(async () => new Headers(await headers())), - }) + Request, + Request.of({ + getSource: () => Effect.succeed('nextjs'), + getHeaders: () => Effect.promise(async () => new Headers(await headers())) + }) ); const DbLive = Db.Default; const RuntimeLayer = () => { - const CoreLayer = pipe( - BetterAuth.Default, - Layer.provideMerge(DbLive), - Layer.provideMerge(CookiesLive), - Layer.provideMerge(RequestLive), - Layer.provideMerge(NextjsRuntimeTagLive) - ); + const CoreLayer = pipe( + BetterAuth.Default, + Layer.provideMerge(DbLive), + Layer.provideMerge(CookiesLive), + Layer.provideMerge(RequestLive), + Layer.provideMerge(NextjsRuntimeTagLive) + ); - const RepositoryLayer = pipe( - ApiKeyRepository.Default, - Layer.provideMerge(CustomerRepository.Default), - Layer.provideMerge(CheckoutSessionRepository.Default), - Layer.provideMerge(OrganizationRepository.Default), - Layer.provideMerge(PaymentProviderConfigurationProductRepository.Default), - Layer.provideMerge(PaymentProviderRepository.Default), - Layer.provideMerge(PaywallLocationRepository.Default), - Layer.provideMerge(PaywallRepository.Default), - Layer.provideMerge(PerkRepository.Default), - Layer.provideMerge(ProductPerkRepository.Default), - Layer.provideMerge(ProductRepository.Default), - Layer.provideMerge(ProjectRepository.Default) - ); + const RepositoryLayer = pipe( + ApiKeyRepository.Default, + Layer.provideMerge(CustomerRepository.Default), + Layer.provideMerge(CheckoutSessionRepository.Default), + Layer.provideMerge(OrganizationRepository.Default), + Layer.provideMerge(PaymentProviderConfigurationProductRepository.Default), + Layer.provideMerge(PaymentProviderConfigurationRepository.Default), + Layer.provideMerge(PaywallLocationRepository.Default), + Layer.provideMerge(PaywallRepository.Default), + Layer.provideMerge(PerkRepository.Default), + Layer.provideMerge(ProductPerkRepository.Default), + Layer.provideMerge(ProductRepository.Default), + Layer.provideMerge(ProjectRepository.Default) + ); - const ServiceLayer = pipe( - ApiKeyService.Default, - Layer.provideMerge(AuthService.Default), - Layer.provideMerge(CustomerService.Default), - Layer.provideMerge(EnvironmentService.Default), - Layer.provideMerge(OrganizationService.Default), - Layer.provideMerge(PaymentProviderService.Default), - Layer.provideMerge(PaywallLocationService.Default), - Layer.provideMerge(PaywallService.Default), - Layer.provideMerge(PerkService.Default), - Layer.provideMerge(ProductService.Default), - Layer.provideMerge(ProjectService.Default), - Layer.provideMerge(SdkService.Default), - Layer.provideMerge(UserService.Default), - Layer.provideMerge(DevCheckoutService.Default) - ); + const ServiceLayer = pipe( + ApiKeyService.Default, + Layer.provideMerge(AuthService.Default), + Layer.provideMerge(CustomerService.Default), + Layer.provideMerge(EnvironmentService.Default), + Layer.provideMerge(OrganizationService.Default), + Layer.provideMerge(PaymentProviderService.Default), + Layer.provideMerge(PaywallLocationService.Default), + Layer.provideMerge(PaywallService.Default), + Layer.provideMerge(PerkService.Default), + Layer.provideMerge(ProductService.Default), + Layer.provideMerge(ProjectService.Default), + Layer.provideMerge(SdkService.Default), + Layer.provideMerge(UserService.Default), + Layer.provideMerge(DevCheckoutService.Default) + ); - return pipe( - ServiceLayer, - Layer.provideMerge(RepositoryLayer), - Layer.provideMerge(CoreLayer) - ); + return pipe( + ServiceLayer, + Layer.provideMerge(RepositoryLayer), + Layer.provideMerge(CoreLayer) + ); }; export const NextjsRuntime = ManagedRuntime.make(RuntimeLayer()); const createNextjsRuntime = () => { - return ManagedRuntime.make(RuntimeLayer()); + return ManagedRuntime.make(RuntimeLayer()); }; export class NextjsErrorResponse extends Schema.TaggedError()( - "NextjsErrorResponse", - { - code: Schema.String, - message: Schema.String, - } + 'NextjsErrorResponse', + { + code: Schema.String, + message: Schema.String + } ) {} // export const createEffectHandler = (context: HonoContextType) => (effect: Effect.Effect): => { @@ -177,212 +180,212 @@ export class NextjsErrorResponse extends Schema.TaggedError type GenericErrors = NotFoundError | ForbiddenError | UnauthorizedError; type SystemErrors = - | CookiesError - | DatabaseError - | BetterAuthError - | InvalidSourceError; + | CookiesError + | DatabaseError + | BetterAuthError + | InvalidSourceError; type AcceptableErrorTypes = - | NextjsErrorResponse - | GenericErrors - | SystemErrors - | MissingSecretKeyError - | MissingPublishableKeyError - | InvalidSecretKeyError - | InvalidPublishableKeyError - | MissingAppUserIdError - | MissingEnvironmentError - | MissingProjectIdError - | ProjectNotFoundInSessionError - | OrganizationNotFoundInSessionError - | InvalidEnvironmentError - | EnvironmentCookieNotFoundError; + | NextjsErrorResponse + | GenericErrors + | SystemErrors + | MissingSecretKeyError + | MissingPublishableKeyError + | InvalidSecretKeyError + | InvalidPublishableKeyError + | MissingAppUserIdError + | MissingEnvironmentError + | MissingProjectIdError + | ProjectNotFoundInSessionError + | OrganizationNotFoundInSessionError + | InvalidEnvironmentError + | EnvironmentCookieNotFoundError; type AvailableServices = Layer.Layer.Success>; const handleGlobalErrors = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - effect: Effect.Effect - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: is ok + effect: Effect.Effect + // biome-ignore lint/suspicious/noExplicitAny: is ok ): Effect.Effect => { - return pipe( - effect, - Effect.catchTags({ - NotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }) - ), - ForbiddenError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "FORBIDDEN", - message: error.message, - }) - ), - UnauthorizedError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "UNAUTHORIZED", - message: error.message, - }) - ), - CookiesError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }) - ), - DatabaseError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }) - ), - BetterAuthError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }) - ), - InvalidSourceError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }) - ), - MissingEnvironmentError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }) - ), - MissingSecretKeyError: () => - Effect.fail( - new NextjsErrorResponse({ - code: "UNAUTHORIZED", - message: "Missing secret key error occured in nextjs runtime", - }) - ), - MissingPublishableKeyError: () => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: "Missing secret key error occured in nextjs runtime", - }) - ), - InvalidSecretKeyError: () => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: "Invalid secret key error occured in nextjs runtime", - }) - ), - InvalidPublishableKeyError: () => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: "Invalid publishable key error occured in nextjs runtime", - }) - ), - MissingAppUserIdError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "UNAUTHORIZED", - message: error.message, - }) - ), - MissingProjectIdError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }) - ), - ProjectNotFoundInSessionError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }) - ), - InvalidEnvironmentError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }) - ), - EnvironmentCookieNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }) - ), - OrganizationNotFoundInSessionError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }) - ), - }) - ); + return pipe( + effect, + Effect.catchTags({ + NotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ), + ForbiddenError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'FORBIDDEN', + message: error.message + }) + ), + UnauthorizedError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'UNAUTHORIZED', + message: error.message + }) + ), + CookiesError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ), + DatabaseError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ), + BetterAuthError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ), + InvalidSourceError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ), + MissingEnvironmentError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ), + MissingSecretKeyError: () => + Effect.fail( + new NextjsErrorResponse({ + code: 'UNAUTHORIZED', + message: 'Missing secret key error occured in nextjs runtime' + }) + ), + MissingPublishableKeyError: () => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Missing secret key error occured in nextjs runtime' + }) + ), + InvalidSecretKeyError: () => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Invalid secret key error occured in nextjs runtime' + }) + ), + InvalidPublishableKeyError: () => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Invalid publishable key error occured in nextjs runtime' + }) + ), + MissingAppUserIdError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'UNAUTHORIZED', + message: error.message + }) + ), + MissingProjectIdError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ), + ProjectNotFoundInSessionError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ), + InvalidEnvironmentError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ), + EnvironmentCookieNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ), + OrganizationNotFoundInSessionError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ) + }) + ); }; export const runServerEffect = async ( - effect: Effect.Effect + effect: Effect.Effect ): Promise> => { - const runtime = createNextjsRuntime(); - const result = await runtime.runPromiseExit( - pipe( - effect, - Effect.flatMap((result) => { - return Effect.succeed(ok(result)); - }), - handleGlobalErrors, - Effect.catchTags({ - NextjsErrorResponse: (error) => Effect.succeed(err(error)), - }), - Effect.catchAll((error) => { - console.error(error); - return Effect.succeed( - err( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: "Internal server error", - }) - ) - ); - }) - ) - ); + const runtime = createNextjsRuntime(); + const result = await runtime.runPromiseExit( + pipe( + effect, + Effect.flatMap((result) => { + return Effect.succeed(ok(result)); + }), + handleGlobalErrors, + Effect.catchTags({ + NextjsErrorResponse: (error) => Effect.succeed(err(error)) + }), + Effect.catchAll((error) => { + Effect.logError(error); + return Effect.succeed( + err( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal server error' + }) + ) + ); + }) + ) + ); - return Exit.match(result, { - onSuccess: (value) => value, - onFailure: (error) => { - if (Cause.isDie(error)) { - const defects = Cause.defects(error); - for (const defect of defects) { - if (isDynamicServerError(defect)) { - unstable_rethrow(defect); - } - } - } + return Exit.match(result, { + onSuccess: (value) => value, + onFailure: (error) => { + if (Cause.isDie(error)) { + const defects = Cause.defects(error); + for (const defect of defects) { + if (isDynamicServerError(defect)) { + unstable_rethrow(defect); + } + } + } - return err( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: "Internal server error", - }) - ); - }, - }); + return err( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal server error' + }) + ); + } + }); }; diff --git a/apps/web/lib/effect/runtimes/tags.ts b/apps/web/lib/effect/runtimes/tags.ts index 6f6de102f..e2329dd4e 100644 --- a/apps/web/lib/effect/runtimes/tags.ts +++ b/apps/web/lib/effect/runtimes/tags.ts @@ -1,12 +1,11 @@ -import { Context } from "effect"; +import { Context } from 'effect'; -export class HonoRuntimeTag extends Context.Tag("app/HonoRuntimeTag")< - HonoRuntimeTag, - "hono" +export class HonoRuntimeTag extends Context.Tag('app/HonoRuntimeTag')< + HonoRuntimeTag, + 'hono' >() {} - -export class NextjsRuntimeTag extends Context.Tag("app/NextjsRuntimeTag")< - NextjsRuntimeTag, - "nextjs" +export class NextjsRuntimeTag extends Context.Tag('app/NextjsRuntimeTag')< + NextjsRuntimeTag, + 'nextjs' >() {} diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index e70d61254..54fa4aacf 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -1,60 +1,59 @@ -import { createEnv } from "@t3-oss/env-nextjs"; -import { vercel } from "@t3-oss/env-nextjs/presets-zod"; -import { z } from "zod"; +import { createEnv } from '@t3-oss/env-nextjs'; +import { vercel } from '@t3-oss/env-nextjs/presets-zod'; +import { z } from 'zod'; // import { env as authEnv } from "@voidhash/auth/env"; export const env = createEnv({ - extends: [vercel()], - shared: { - NODE_ENV: z - .enum(["development", "production", "test"]) - .default("development"), - }, - /** - * Specify your server-side environment variables schema here. - * This way you can ensure the app isn't built with invalid env vars. - */ - server: { - BETTER_AUTH_SECRET: z.string(), - DATABASE_HOST: z.string(), - DATABASE_PORT: z.string().optional(), - DATABASE_USERNAME: z.string(), - DATABASE_PASSWORD: z.string(), - DATABASE_NAME: z.string().optional(), - VOIDHASH_SECRET_KEY: z.string(), - TRIGGER_PROJECT_ID: z.string(), - TRIGGER_SECRET_KEY: z.string(), - GITHUB_CLIENT_ID: z.string(), - GITHUB_CLIENT_SECRET: z.string(), - POLAR_ACCESS_TOKEN: z.string(), - AXIOM_LOGS_DATASET: z.string(), - AXIOM_TOKEN: z.string(), - AXIOM_LOG_LEVEL: z - .enum(["debug", "info", "warn", "error", "fatal"]) - .default("info"), - }, + extends: [vercel()], + shared: { + NODE_ENV: z + .enum(['development', 'production', 'test']) + .default('development') + }, + /** + * Specify your server-side environment variables schema here. + * This way you can ensure the app isn't built with invalid env vars. + */ + server: { + BETTER_AUTH_SECRET: z.string(), + DATABASE_HOST: z.string(), + DATABASE_PORT: z.string().optional(), + DATABASE_USERNAME: z.string(), + DATABASE_PASSWORD: z.string(), + DATABASE_NAME: z.string().optional(), + VOIDHASH_SECRET_KEY: z.string(), + TRIGGER_PROJECT_ID: z.string(), + TRIGGER_SECRET_KEY: z.string(), + GITHUB_CLIENT_ID: z.string(), + GITHUB_CLIENT_SECRET: z.string(), + POLAR_ACCESS_TOKEN: z.string(), + AXIOM_LOGS_DATASET: z.string(), + AXIOM_TOKEN: z.string(), + AXIOM_LOG_LEVEL: z + .enum(['debug', 'info', 'warn', 'error', 'fatal']) + .default('info') + }, - /** - * Specify your client-side environment variables schema here. - * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. - */ - client: { - NEXT_PUBLIC_APP_NAME: z.string(), - NEXT_PUBLIC_APP_DOMAIN: z.string(), - NEXT_PUBLIC_APP_SHORT_DOMAIN: z.string(), - NEXT_PUBLIC_VERCEL_ENV: z.string(), - }, - /** - * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. - */ - experimental__runtimeEnv: { - NODE_ENV: process.env.NODE_ENV, - NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, - NEXT_PUBLIC_APP_DOMAIN: process.env.NEXT_PUBLIC_APP_DOMAIN, - NEXT_PUBLIC_APP_SHORT_DOMAIN: process.env.NEXT_PUBLIC_APP_SHORT_DOMAIN, - NEXT_PUBLIC_VERCEL_ENV: process.env.NEXT_PUBLIC_VERCEL_ENV, - }, - skipValidation: - !!process.env.CI || process.env.npm_lifecycle_event === "lint", + /** + * Specify your client-side environment variables schema here. + * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. + */ + client: { + NEXT_PUBLIC_APP_NAME: z.string(), + NEXT_PUBLIC_APP_DOMAIN: z.string(), + NEXT_PUBLIC_APP_SHORT_DOMAIN: z.string(), + NEXT_PUBLIC_VERCEL_ENV: z.string() + }, + /** + * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. + */ + experimental__runtimeEnv: { + NODE_ENV: process.env.NODE_ENV, + NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, + NEXT_PUBLIC_APP_DOMAIN: process.env.NEXT_PUBLIC_APP_DOMAIN, + NEXT_PUBLIC_APP_SHORT_DOMAIN: process.env.NEXT_PUBLIC_APP_SHORT_DOMAIN, + NEXT_PUBLIC_VERCEL_ENV: process.env.NEXT_PUBLIC_VERCEL_ENV + }, + skipValidation: !!process.env.CI || process.env.npm_lifecycle_event === 'lint' }); diff --git a/apps/web/lib/id/generate.ts b/apps/web/lib/id/generate.ts index c5d8db30e..ca5482cb1 100644 --- a/apps/web/lib/id/generate.ts +++ b/apps/web/lib/id/generate.ts @@ -1,34 +1,34 @@ -import { createId } from "@paralleldrive/cuid2"; +import { createId } from '@paralleldrive/cuid2'; const prefixes = { - test: "test", - request: "req", - user: "user", - organization: "org", - apiSecretKey: "api_sk", - apiPublishableKey: "api_pk", - apiPublishableKeyTesting: "api_pk_test", - customer: "cust", - purchase: "pur", - paywall: "pw", - paywallProduct: "pw_prod", - paymentProviderConfiguration: "pp_conf", - paymentProviderProduct: "pp_prod", - product: "prod", - project: "proj", - perk: "perk", - productPerk: "prod_perk", - paywallLocation: "pw_loc", - customerUnlockedPerk: "cust_perk", - checkoutSession: "ch_sess", - transaction: "tx", - outbox: "outbox", - subscription: "sub", - appStoreTransaction: "app_store_tx", + test: 'test', + request: 'req', + user: 'user', + organization: 'org', + apiSecretKey: 'api_sk', + apiPublishableKey: 'api_pk', + apiPublishableKeyTesting: 'api_pk_test', + customer: 'cust', + purchase: 'pur', + paywall: 'pw', + paywallProduct: 'pw_prod', + paymentProviderConfiguration: 'pp_conf', + paymentProviderProduct: 'pp_prod', + product: 'prod', + project: 'proj', + perk: 'perk', + productPerk: 'prod_perk', + paywallLocation: 'pw_loc', + customerUnlockedPerk: 'cust_perk', + checkoutSession: 'ch_sess', + transaction: 'tx', + outbox: 'outbox', + subscription: 'sub', + appStoreTransaction: 'app_store_tx' } as const; export const generateId = ( - prefix: TPrefix + prefix: TPrefix ) => { - return `${prefixes[prefix]}_${createId()}`; + return `${prefixes[prefix]}_${createId()}`; }; diff --git a/apps/web/lib/logger/console.ts b/apps/web/lib/logger/console.ts index 216deba1d..15b2bb205 100644 --- a/apps/web/lib/logger/console.ts +++ b/apps/web/lib/logger/console.ts @@ -1,58 +1,59 @@ -import { Log, type LogSchema } from "@/lib/logger/schema"; -import type { Fields, Logger } from "@/lib/logger/types"; +/** biome-ignore-all lint/suspicious/noConsole: console logger */ +import { Log, type LogSchema } from '@/lib/logger/schema'; +import type { Fields, Logger } from '@/lib/logger/types'; export class ConsoleLogger implements Logger { - private requestId: string; - private readonly environment: LogSchema["environment"]; - private readonly application: LogSchema["application"]; - private readonly defaultFields: Fields; + private requestId: string; + private readonly environment: LogSchema['environment']; + private readonly application: LogSchema['application']; + private readonly defaultFields: Fields; - constructor(opts: { - requestId: string; - environment: LogSchema["environment"]; - application: LogSchema["application"]; - defaultFields?: Fields; - }) { - this.requestId = opts.requestId; - this.environment = opts.environment; - this.application = opts.application; - this.defaultFields = opts.defaultFields ?? {}; - } + constructor(opts: { + requestId: string; + environment: LogSchema['environment']; + application: LogSchema['application']; + defaultFields?: Fields; + }) { + this.requestId = opts.requestId; + this.environment = opts.environment; + this.application = opts.application; + this.defaultFields = opts.defaultFields ?? {}; + } - private marshal( - level: "debug" | "info" | "warn" | "error" | "fatal", - message: string, - fields?: Fields - ): string { - return new Log({ - type: "log", - environment: this.environment, - application: this.application, - requestId: this.requestId, - time: Date.now(), - level, - message, - context: { ...this.defaultFields, ...fields }, - }).toString(); - } + debug(message: string, fields?: Fields): void { + console.debug(this.marshal('debug', message, fields)); + } + info(message: string, fields?: Fields): void { + console.info(this.marshal('info', message, fields)); + } + warn(message: string, fields?: Fields): void { + console.warn(this.marshal('warn', message, fields)); + } + error(message: string, fields?: Fields): void { + console.error(this.marshal('error', message, fields)); + } + fatal(message: string, fields?: Fields): void { + console.error(this.marshal('fatal', message, fields)); + } - public debug(message: string, fields?: Fields): void { - console.debug(this.marshal("debug", message, fields)); - } - public info(message: string, fields?: Fields): void { - console.info(this.marshal("info", message, fields)); - } - public warn(message: string, fields?: Fields): void { - console.warn(this.marshal("warn", message, fields)); - } - public error(message: string, fields?: Fields): void { - console.error(this.marshal("error", message, fields)); - } - public fatal(message: string, fields?: Fields): void { - console.error(this.marshal("fatal", message, fields)); - } + setRequestId(requestId: string): void { + this.requestId = requestId; + } - public setRequestId(requestId: string): void { - this.requestId = requestId; - } + private marshal( + level: 'debug' | 'info' | 'warn' | 'error' | 'fatal', + message: string, + fields?: Fields + ): string { + return new Log({ + type: 'log', + environment: this.environment, + application: this.application, + requestId: this.requestId, + time: Date.now(), + level, + message, + context: { ...this.defaultFields, ...fields } + }).toString(); + } } diff --git a/apps/web/lib/logger/pino.ts b/apps/web/lib/logger/pino.ts index 5c36bc84f..068fe4a54 100644 --- a/apps/web/lib/logger/pino.ts +++ b/apps/web/lib/logger/pino.ts @@ -1,84 +1,84 @@ -import { type LogSchema } from "@/lib/logger/schema"; -import type { Fields, Logger } from "@/lib/logger/types"; -import pino from "pino"; -import { env } from "../env"; +import pino from 'pino'; +import type { LogSchema } from '@/lib/logger/schema'; +import type { Fields, Logger } from '@/lib/logger/types'; +import { env } from '../env'; const pinoLogger = pino( - { level: env.AXIOM_LOG_LEVEL }, - pino.transport({ - target: "@axiomhq/pino", - options: { - dataset: env.AXIOM_LOGS_DATASET, - token: env.AXIOM_TOKEN, - }, - }) + { level: env.AXIOM_LOG_LEVEL }, + pino.transport({ + target: '@axiomhq/pino', + options: { + dataset: env.AXIOM_LOGS_DATASET, + token: env.AXIOM_TOKEN + } + }) ); export class PinoLogger implements Logger { - private requestId: string; - private readonly environment: LogSchema["environment"]; - private readonly application: LogSchema["application"]; - private readonly defaultFields: Fields; + private requestId: string; + private readonly environment: LogSchema['environment']; + private readonly application: LogSchema['application']; + private readonly defaultFields: Fields; - constructor(opts: { - requestId: string; - environment: LogSchema["environment"]; - application: LogSchema["application"]; - defaultFields?: Fields; - }) { - this.requestId = opts.requestId; - this.environment = opts.environment; - this.application = opts.application; - this.defaultFields = opts.defaultFields ?? {}; - } + constructor(opts: { + requestId: string; + environment: LogSchema['environment']; + application: LogSchema['application']; + defaultFields?: Fields; + }) { + this.requestId = opts.requestId; + this.environment = opts.environment; + this.application = opts.application; + this.defaultFields = opts.defaultFields ?? {}; + } - public debug(message: string, fields?: Fields): void { - pinoLogger.debug(message, { - environment: this.environment, - application: this.application, - requestId: this.requestId, - ...this.defaultFields, - ...fields, - }); - } - public info(message: string, fields?: Fields): void { - pinoLogger.info(message, { - environment: this.environment, - application: this.application, - requestId: this.requestId, - ...this.defaultFields, - ...fields, - }); - } - public warn(message: string, fields?: Fields): void { - pinoLogger.warn(message, { - environment: this.environment, - application: this.application, - requestId: this.requestId, - ...this.defaultFields, - ...fields, - }); - } - public error(message: string, fields?: Fields): void { - pinoLogger.error(message, { - environment: this.environment, - application: this.application, - requestId: this.requestId, - ...this.defaultFields, - ...fields, - }); - } - public fatal(message: string, fields?: Fields): void { - pinoLogger.fatal(message, { - environment: this.environment, - application: this.application, - requestId: this.requestId, - ...this.defaultFields, - ...fields, - }); - } + debug(message: string, fields?: Fields): void { + pinoLogger.debug(message, { + environment: this.environment, + application: this.application, + requestId: this.requestId, + ...this.defaultFields, + ...fields + }); + } + info(message: string, fields?: Fields): void { + pinoLogger.info(message, { + environment: this.environment, + application: this.application, + requestId: this.requestId, + ...this.defaultFields, + ...fields + }); + } + warn(message: string, fields?: Fields): void { + pinoLogger.warn(message, { + environment: this.environment, + application: this.application, + requestId: this.requestId, + ...this.defaultFields, + ...fields + }); + } + error(message: string, fields?: Fields): void { + pinoLogger.error(message, { + environment: this.environment, + application: this.application, + requestId: this.requestId, + ...this.defaultFields, + ...fields + }); + } + fatal(message: string, fields?: Fields): void { + pinoLogger.fatal(message, { + environment: this.environment, + application: this.application, + requestId: this.requestId, + ...this.defaultFields, + ...fields + }); + } - public setRequestId(requestId: string): void { - this.requestId = requestId; - } + setRequestId(requestId: string): void { + this.requestId = requestId; + } } diff --git a/apps/web/lib/logger/schema.ts b/apps/web/lib/logger/schema.ts index 0aac50614..acd90c749 100644 --- a/apps/web/lib/logger/schema.ts +++ b/apps/web/lib/logger/schema.ts @@ -1,50 +1,50 @@ -import { metricSchema } from "@/lib/metrics/schema"; -import { z } from "zod"; +import { z } from 'zod'; +import { metricSchema } from '@/lib/metrics/schema'; export const logContext = z.object({ - requestId: z.string(), + requestId: z.string() }); const commonFields = z.object({ - environment: z.enum([ - "test", - "development", - "preview", - "canary", - "production", - "unknown", - ]), - application: z.enum(["api", "web"]), - isolateId: z.string().optional(), - requestId: z.string(), - time: z.number(), + environment: z.enum([ + 'test', + 'development', + 'preview', + 'canary', + 'production', + 'unknown' + ]), + application: z.enum(['api', 'web']), + isolateId: z.string().optional(), + requestId: z.string(), + time: z.number() }); -export const logSchema = z.discriminatedUnion("type", [ - commonFields.merge( - z.object({ - type: z.literal("log"), - level: z.enum(["debug", "info", "warn", "error", "fatal"]), - message: z.string(), - context: z.record(z.string(), z.any()), - }), - ), - commonFields.merge( - z.object({ - type: z.literal("metric"), - metric: metricSchema, - }), - ), +export const logSchema = z.discriminatedUnion('type', [ + commonFields.merge( + z.object({ + type: z.literal('log'), + level: z.enum(['debug', 'info', 'warn', 'error', 'fatal']), + message: z.string(), + context: z.record(z.string(), z.any()) + }) + ), + commonFields.merge( + z.object({ + type: z.literal('metric'), + metric: metricSchema + }) + ) ]); export type LogSchema = z.infer; export class Log { - public readonly log: TLog; + readonly log: TLog; - constructor(log: TLog) { - this.log = log; - } + constructor(log: TLog) { + this.log = log; + } - public toString(): string { - return JSON.stringify(this.log); - } + toString(): string { + return JSON.stringify(this.log); + } } diff --git a/apps/web/lib/logger/types.ts b/apps/web/lib/logger/types.ts index afc61b1f9..ebc58900a 100644 --- a/apps/web/lib/logger/types.ts +++ b/apps/web/lib/logger/types.ts @@ -1,10 +1,10 @@ export type Fields = { - [field: string]: unknown; + [field: string]: unknown; }; export interface Logger { - debug(message: string, fields?: Fields): void; - info(message: string, fields?: Fields): void; - warn(message: string, fields?: Fields): void; - error(message: string, fields?: Fields): void; + debug(message: string, fields?: Fields): void; + info(message: string, fields?: Fields): void; + warn(message: string, fields?: Fields): void; + error(message: string, fields?: Fields): void; } diff --git a/apps/web/lib/metrics/schema.ts b/apps/web/lib/metrics/schema.ts index e88ec2f50..fef7b128b 100644 --- a/apps/web/lib/metrics/schema.ts +++ b/apps/web/lib/metrics/schema.ts @@ -1,154 +1,154 @@ // Credit to https://github.com/unkeyed/unkey -import { z } from "zod"; +import { z } from 'zod'; -export const metricSchema = z.discriminatedUnion("metric", [ - z.object({ - metric: z.literal("metric.cache.read"), - key: z.string(), - hit: z.boolean(), - status: z.enum(["fresh", "stale"]).optional(), - latency: z.number(), - tier: z.string(), - namespace: z.string(), - }), - // z.object({ - // metric: z.literal("metric.cache.write"), - // key: z.string(), - // tier: z.string(), - // latency: z.number(), - // namespace: z.string(), - // }), - // z.object({ - // metric: z.literal("metric.cache.remove"), - // key: z.string(), - // tier: z.string(), - // namespace: z.string(), - // latency: z.number(), - // }), - // z.object({ - // metric: z.literal("metric.cache.size"), - // name: z.string(), - // tier: z.literal("memory"), - // size: z.number(), - // }), - // z.object({ - // metric: z.literal("metric.fetch.egress"), - // url: z.string(), - // latency: z.number(), - // status: z.number(), - // }), - // z.object({ - // metric: z.literal("metric.key.verification"), - // valid: z.boolean(), - // code: z.string(), - // workspaceId: z.string().optional(), - // apiId: z.string().optional(), - // keyId: z.string().optional(), - // }), - // z.object({ - // metric: z.literal("metric.http.request"), - // host: z.string(), - // path: z.string(), - // method: z.string(), - // status: z.number(), - // // ms since worker initilized for the first time - // // a non zero value means the worker is reused - // isolateLifetime: z.number().optional(), - // isolateId: z.string().optional(), - // error: z.string().optional(), - // coldStart: z.boolean().optional(), - // serviceLatency: z.number(), - // // Regional data might be different on non-cloudflare deployments - // colo: z.string().optional(), - // continent: z.string().optional(), - // country: z.string().optional(), - // city: z.string().optional(), - // userAgent: z.string().optional(), - // fromAgent: z.string().optional(), - // context: z.record(z.unknown()), - // }), - // z.object({ - // metric: z.literal("metric.db.read"), - // query: z.enum([ - // "getKeyAndApiByHash", - // "loadFromOrigin", - // "getKeysByKeyAuthId", - // ]), - // latency: z.number(), - // dbRes: z.string().optional(), - // sql: z.string().optional(), - // }), - // z.object({ - // metric: z.literal("metric.ratelimit"), - // workspaceId: z.string(), - // namespaceId: z.string().optional(), - // identifier: z.string(), - // latency: z.number(), - // mode: z.enum(["sync", "async", "cloudflare"]), - // success: z.boolean().optional(), - // error: z.boolean().optional(), - // source: z.enum(["agent", "durable_object", "cloudflare"]), - // }), - // z.object({ - // metric: z.literal("metric.usagelimit"), - // keyId: z.string(), - // latency: z.number(), - // }), - // z.object({ - // metric: z.literal("metric.ratelimit.accuracy"), - // workspaceId: z.string(), - // namespaceId: z.string().optional(), - // identifier: z.string(), - // responded: z.boolean(), - // correct: z.boolean(), - // }), +export const metricSchema = z.discriminatedUnion('metric', [ + z.object({ + metric: z.literal('metric.cache.read'), + key: z.string(), + hit: z.boolean(), + status: z.enum(['fresh', 'stale']).optional(), + latency: z.number(), + tier: z.string(), + namespace: z.string() + }) + // z.object({ + // metric: z.literal("metric.cache.write"), + // key: z.string(), + // tier: z.string(), + // latency: z.number(), + // namespace: z.string(), + // }), + // z.object({ + // metric: z.literal("metric.cache.remove"), + // key: z.string(), + // tier: z.string(), + // namespace: z.string(), + // latency: z.number(), + // }), + // z.object({ + // metric: z.literal("metric.cache.size"), + // name: z.string(), + // tier: z.literal("memory"), + // size: z.number(), + // }), + // z.object({ + // metric: z.literal("metric.fetch.egress"), + // url: z.string(), + // latency: z.number(), + // status: z.number(), + // }), + // z.object({ + // metric: z.literal("metric.key.verification"), + // valid: z.boolean(), + // code: z.string(), + // workspaceId: z.string().optional(), + // apiId: z.string().optional(), + // keyId: z.string().optional(), + // }), + // z.object({ + // metric: z.literal("metric.http.request"), + // host: z.string(), + // path: z.string(), + // method: z.string(), + // status: z.number(), + // // ms since worker initilized for the first time + // // a non zero value means the worker is reused + // isolateLifetime: z.number().optional(), + // isolateId: z.string().optional(), + // error: z.string().optional(), + // coldStart: z.boolean().optional(), + // serviceLatency: z.number(), + // // Regional data might be different on non-cloudflare deployments + // colo: z.string().optional(), + // continent: z.string().optional(), + // country: z.string().optional(), + // city: z.string().optional(), + // userAgent: z.string().optional(), + // fromAgent: z.string().optional(), + // context: z.record(z.unknown()), + // }), + // z.object({ + // metric: z.literal("metric.db.read"), + // query: z.enum([ + // "getKeyAndApiByHash", + // "loadFromOrigin", + // "getKeysByKeyAuthId", + // ]), + // latency: z.number(), + // dbRes: z.string().optional(), + // sql: z.string().optional(), + // }), + // z.object({ + // metric: z.literal("metric.ratelimit"), + // workspaceId: z.string(), + // namespaceId: z.string().optional(), + // identifier: z.string(), + // latency: z.number(), + // mode: z.enum(["sync", "async", "cloudflare"]), + // success: z.boolean().optional(), + // error: z.boolean().optional(), + // source: z.enum(["agent", "durable_object", "cloudflare"]), + // }), + // z.object({ + // metric: z.literal("metric.usagelimit"), + // keyId: z.string(), + // latency: z.number(), + // }), + // z.object({ + // metric: z.literal("metric.ratelimit.accuracy"), + // workspaceId: z.string(), + // namespaceId: z.string().optional(), + // identifier: z.string(), + // responded: z.boolean(), + // correct: z.boolean(), + // }), - // z.object({ - // metric: z.literal("metric.vault.latency"), - // op: z.enum([ - // "encrypt", - // "encryptBulk", - // "decrypt", - // "reEncrypt", - // "createDEK", - // "liveness", - // "reEncryptDEKs", - // ]), - // latency: z.number(), - // }), - // z.object({ - // metric: z.literal("metric.agent.latency"), - // op: z.enum([ - // "liveness", - // "ratelimit", - // "multiRatelimit", - // "encrypt", - // "decrypt", - // ]), - // latency: z.number(), - // }), - // z.object({ - // metric: z.literal("metric.server.latency"), - // status: z.number(), - // country: z.string(), - // continent: z.string(), - // latency: z.number(), - // platform: z.string(), - // colo: z.string(), - // }), - // z.object({ - // metric: z.literal("metric.db.transaction"), - // name: z.string(), - // path: z.string().optional(), - // latency: z.number(), - // attempts: z.number().optional(), - // }), - // z.object({ - // metric: z.literal("metric.ratelimit.aws"), - // awsLatency: z.number(), - // cfPassed: z.boolean(), - // awsPassed: z.boolean(), - // }), + // z.object({ + // metric: z.literal("metric.vault.latency"), + // op: z.enum([ + // "encrypt", + // "encryptBulk", + // "decrypt", + // "reEncrypt", + // "createDEK", + // "liveness", + // "reEncryptDEKs", + // ]), + // latency: z.number(), + // }), + // z.object({ + // metric: z.literal("metric.agent.latency"), + // op: z.enum([ + // "liveness", + // "ratelimit", + // "multiRatelimit", + // "encrypt", + // "decrypt", + // ]), + // latency: z.number(), + // }), + // z.object({ + // metric: z.literal("metric.server.latency"), + // status: z.number(), + // country: z.string(), + // continent: z.string(), + // latency: z.number(), + // platform: z.string(), + // colo: z.string(), + // }), + // z.object({ + // metric: z.literal("metric.db.transaction"), + // name: z.string(), + // path: z.string().optional(), + // latency: z.number(), + // attempts: z.number().optional(), + // }), + // z.object({ + // metric: z.literal("metric.ratelimit.aws"), + // awsLatency: z.number(), + // cfPassed: z.boolean(), + // awsPassed: z.boolean(), + // }), ]); export type Metric = z.infer; diff --git a/apps/web/lib/middleware/api.ts b/apps/web/lib/middleware/api.ts index e2d409f94..dd5ff2537 100644 --- a/apps/web/lib/middleware/api.ts +++ b/apps/web/lib/middleware/api.ts @@ -1,10 +1,10 @@ -import { parse } from "./utils/parse"; -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from 'next/server'; +import { parse } from './utils/parse'; export default function ApiMiddleware(req: NextRequest) { - const { fullPath } = parse(req); + const { fullPath } = parse(req); - // Note: we don't have to account for paths starting with `/api` - // since they're automatically excluded via our middleware matcher - return NextResponse.rewrite(new URL(`/api${fullPath}`, req.url)); + // Note: we don't have to account for paths starting with `/api` + // since they're automatically excluded via our middleware matcher + return NextResponse.rewrite(new URL(`/api${fullPath}`, req.url)); } diff --git a/apps/web/lib/middleware/app.ts b/apps/web/lib/middleware/app.ts index da902d85c..0e0b3c7f3 100644 --- a/apps/web/lib/middleware/app.ts +++ b/apps/web/lib/middleware/app.ts @@ -1,55 +1,53 @@ -import { getEnvironment } from "../core/environments/utils"; -import { NextMiddlewareCookiesAdapter } from "../nextjs/utils/next-middleware-cookie-adapter"; -import { parse } from "./utils/parse"; -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from 'next/server'; +import { getEnvironment } from '../core/environments/utils'; +import { NextMiddlewareCookiesAdapter } from '../nextjs/utils/next-middleware-cookie-adapter'; +import { parse } from './utils/parse'; export default async function AppMiddleware(req: NextRequest) { - const { fullPath, path, organizationSlug, projectSlug } = parse(req); + const { fullPath, path, organizationSlug, projectSlug } = parse(req); - const sessionCookie = req.cookies.get("better-auth.session_token"); - const secureSessionCookie = req.cookies.get( - "__Secure-better-auth.session_token" - ); + const sessionCookie = req.cookies.get('better-auth.session_token'); + const secureSessionCookie = req.cookies.get( + '__Secure-better-auth.session_token' + ); - // Prevent infinite redirect loop - if ( - !sessionCookie && - !secureSessionCookie && - path !== "/login" && - path !== "/sign-up" - ) { - return NextResponse.redirect( - new URL( - `/login${path === "/" ? "" : `?next=${encodeURIComponent(fullPath)}`}`, - req.url - ) - ); - } + // Prevent infinite redirect loop + if ( + !(sessionCookie || secureSessionCookie) && + path !== '/login' && + path !== '/sign-up' + ) { + return NextResponse.redirect( + new URL( + `/login${path === '/' ? '' : `?next=${encodeURIComponent(fullPath)}`}`, + req.url + ) + ); + } - if ( - organizationSlug && - projectSlug && - !path.includes("/environment-redirect") - ) { - const environmentResult = await getEnvironment( - new NextMiddlewareCookiesAdapter(req), - organizationSlug, - projectSlug - ); - if (environmentResult.isErr()) { - console.log(environmentResult.error); - if (environmentResult.error.code === "NOT_FOUND") { - return NextResponse.redirect( - new URL( - `/${organizationSlug}/${projectSlug}/environment-redirect?next=${encodeURIComponent(fullPath)}`, - req.url - ) - ); - } - return NextResponse.redirect(new URL(`/error`, req.url)); - } - } + if ( + organizationSlug && + projectSlug && + !path.includes('/environment-redirect') + ) { + const environmentResult = await getEnvironment( + new NextMiddlewareCookiesAdapter(req), + organizationSlug, + projectSlug + ); + if (environmentResult.isErr()) { + if (environmentResult.error.code === 'NOT_FOUND') { + return NextResponse.redirect( + new URL( + `/${organizationSlug}/${projectSlug}/environment-redirect?next=${encodeURIComponent(fullPath)}`, + req.url + ) + ); + } + return NextResponse.redirect(new URL('/error', req.url)); + } + } - // otherwise, rewrite the path to /app - return NextResponse.rewrite(new URL(`/app.voidhash.com${fullPath}`, req.url)); + // otherwise, rewrite the path to /app + return NextResponse.rewrite(new URL(`/app.voidhash.com${fullPath}`, req.url)); } diff --git a/apps/web/lib/middleware/checkout.ts b/apps/web/lib/middleware/checkout.ts index 5a90df7f4..e2b17f3ce 100644 --- a/apps/web/lib/middleware/checkout.ts +++ b/apps/web/lib/middleware/checkout.ts @@ -1,10 +1,10 @@ -import { NextRequest, NextResponse } from "next/server"; -import { parse } from "./utils/parse"; +import { type NextRequest, NextResponse } from 'next/server'; +import { parse } from './utils/parse'; export default function CheckoutMiddleware(req: NextRequest) { - const { fullPath } = parse(req); + const { fullPath } = parse(req); - return NextResponse.rewrite( - new URL(`/checkout.voidhash.com${fullPath}`, req.url) - ); + return NextResponse.rewrite( + new URL(`/checkout.voidhash.com${fullPath}`, req.url) + ); } diff --git a/apps/web/lib/middleware/utils/parse.ts b/apps/web/lib/middleware/utils/parse.ts index c3d542bf9..57f96a0f9 100644 --- a/apps/web/lib/middleware/utils/parse.ts +++ b/apps/web/lib/middleware/utils/parse.ts @@ -1,40 +1,42 @@ -import { SHORT_DOMAIN } from "@voidhash/lib"; -import { NextRequest } from "next/server"; +import { SHORT_DOMAIN } from '@voidhash/lib'; +import type { NextRequest } from 'next/server'; + +const WWW_REGEX = /^www./; export const parse = (req: NextRequest) => { - let domain = req.headers.get("host") as string; - // remove www. from domain and convert to lowercase - domain = domain.replace(/^www./, "").toLowerCase(); - if (domain === "voidhash.localhost:3000" || domain.endsWith(".vercel.app")) { - // for local development and preview URLs - domain = SHORT_DOMAIN; - } + let domain = req.headers.get('host') as string; + // remove www. from domain and convert to lowercase + domain = domain.replace(WWW_REGEX, '').toLowerCase(); + if (domain === 'voidhash.localhost:3000' || domain.endsWith('.vercel.app')) { + // for local development and preview URLs + domain = SHORT_DOMAIN; + } - // path is the path of the URL (e.g. dub.sh/stats/github -> /stats/github) - const path = req.nextUrl.pathname; - const pathParts = path.split("/"); - const organizationSlug = pathParts[1] !== "~" ? pathParts[1] : null; - const projectSlug = pathParts[2] !== "~" ? pathParts[2] : null; + // path is the path of the URL (e.g. dub.sh/stats/github -> /stats/github) + const path = req.nextUrl.pathname; + const pathParts = path.split('/'); + const organizationSlug = pathParts[1] !== '~' ? pathParts[1] : null; + const projectSlug = pathParts[2] !== '~' ? pathParts[2] : null; - // fullPath is the full URL path (along with search params) - const searchParams = req.nextUrl.searchParams.toString(); - const searchParamsObj = Object.fromEntries(req.nextUrl.searchParams); - const searchParamsString = searchParams.length > 0 ? `?${searchParams}` : ""; - const fullPath = `${path}${searchParamsString}`; + // fullPath is the full URL path (along with search params) + const searchParams = req.nextUrl.searchParams.toString(); + const searchParamsObj = Object.fromEntries(req.nextUrl.searchParams); + const searchParamsString = searchParams.length > 0 ? `?${searchParams}` : ''; + const fullPath = `${path}${searchParamsString}`; - // Here, we are using decodeURIComponent to handle foreign languages like Hebrew - const key = decodeURIComponent(path.split("/")[1] ?? ""); // key is the first part of the path (e.g. dub.sh/stats/github -> stats) - const fullKey = decodeURIComponent(path.slice(1)); // fullKey is the full path without the first slash (to account for multi-level subpaths, e.g. d.to/github/repo -> github/repo) + // Here, we are using decodeURIComponent to handle foreign languages like Hebrew + const key = decodeURIComponent(path.split('/')[1] ?? ''); // key is the first part of the path (e.g. dub.sh/stats/github -> stats) + const fullKey = decodeURIComponent(path.slice(1)); // fullKey is the full path without the first slash (to account for multi-level subpaths, e.g. d.to/github/repo -> github/repo) - return { - domain, - path, - fullPath, - key, - fullKey, - searchParamsObj, - searchParamsString, - organizationSlug, - projectSlug, - }; + return { + domain, + path, + fullPath, + key, + fullKey, + searchParamsObj, + searchParamsString, + organizationSlug, + projectSlug + }; }; diff --git a/apps/web/lib/neverthrow.ts b/apps/web/lib/neverthrow.ts index 3c5592c94..e51750fd3 100644 --- a/apps/web/lib/neverthrow.ts +++ b/apps/web/lib/neverthrow.ts @@ -1,8 +1,8 @@ import { - fromUnknownThrow, - VoidhashInternalServerError, -} from "@voidhash/lib/constants"; -import { Result, ok, err, Ok, Err } from "neverthrow"; + fromUnknownThrow, + type VoidhashInternalServerError +} from '@voidhash/lib/constants'; +import { Err, err, Ok, ok, type Result } from 'neverthrow'; /** * A safe way to execute a function that may throw. @@ -26,31 +26,31 @@ import { Result, ok, err, Ok, Err } from "neverthrow"; * ); */ export function safeTry( - fn: () => Result | TOk + fn: () => Result | TOk ): Result; export function safeTry( - fn: () => Result | TOk, - errorFn: (error: unknown) => SpecificError + fn: () => Result | TOk, + errorFn: (error: unknown) => SpecificError ): Result; export function safeTry( - fn: () => Result | TOk, - errorFn?: (error: unknown) => SpecificError + fn: () => Result | TOk, + errorFn?: (error: unknown) => SpecificError ): Result { - try { - const result = fn(); + try { + const result = fn(); - if (result instanceof Ok) { - return ok(result.value); - } + if (result instanceof Ok) { + return ok(result.value); + } - if (result instanceof Err) { - return err(result.error); - } + if (result instanceof Err) { + return err(result.error); + } - return ok(result); - } catch (error) { - return err(errorFn ? errorFn(error) : fromUnknownThrow(error)); - } + return ok(result); + } catch (error) { + return err(errorFn ? errorFn(error) : fromUnknownThrow(error)); + } } /** @@ -74,33 +74,33 @@ export function safeTry( * } */ export async function safeTryPromise( - fn: () => Promise | TOk> + fn: () => Promise | TOk> ): Promise>; export async function safeTryPromise( - fn: () => Promise | TOk>, - errorFn: (error: unknown) => SpecificError + fn: () => Promise | TOk>, + errorFn: (error: unknown) => SpecificError ): Promise>; export async function safeTryPromise( - fn: () => Promise | TOk>, - errorFn?: (error: unknown) => SpecificError + fn: () => Promise | TOk>, + errorFn?: (error: unknown) => SpecificError ): Promise< - Result + Result > { - try { - const res = await fn(); + try { + const res = await fn(); - if (res instanceof Ok) { - return ok(res.value); - } + if (res instanceof Ok) { + return ok(res.value); + } - if (res instanceof Err) { - return err(res.error); - } + if (res instanceof Err) { + return err(res.error); + } - return ok(res); - } catch (error) { - return err(errorFn ? errorFn(error) : fromUnknownThrow(error)); - } + return ok(res); + } catch (error) { + return err(errorFn ? errorFn(error) : fromUnknownThrow(error)); + } } // class SfnError extends Error { diff --git a/apps/web/lib/nextjs/schema.ts b/apps/web/lib/nextjs/schema.ts index e618c8a3d..4af60093f 100644 --- a/apps/web/lib/nextjs/schema.ts +++ b/apps/web/lib/nextjs/schema.ts @@ -1,232 +1,234 @@ -import { Schema } from "effect"; -import { CustomerOrigin } from "@voidhash/db"; -import { ID_BLACKLIST } from "@voidhash/lib/constants/id-blacklist"; -import { ANONYMOUS_USER_ID_PREFIX } from "../core/sdk/constants"; -import { Environment } from "@voidhash/lib/constants"; +import { CustomerOrigin } from '@voidhash/db'; +import { Environment } from '@voidhash/lib/constants'; +import { ID_BLACKLIST } from '@voidhash/lib/constants/id-blacklist'; +import { Schema } from 'effect'; +import { ANONYMOUS_USER_ID_PREFIX } from '../core/sdk/constants'; // Api Keys export const createSecretKeyInputSchema = Schema.Struct({ - projectId: Schema.String, - name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)), + projectId: Schema.String, + name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)) }); export const deleteSecretKeyInputSchema = Schema.Struct({ - secretKeyId: Schema.String, + secretKeyId: Schema.String }); export const rotateSecretKeyInputSchema = Schema.Struct({ - secretKeyId: Schema.String, + secretKeyId: Schema.String }); // Customers export const createCustomerInputSchema = Schema.Struct({ - projectId: Schema.String, - appUserId: Schema.String, - name: Schema.NullishOr( - Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)) - ), - email: Schema.NullishOr(Schema.String), - origin: Schema.Union( - Schema.Literal(CustomerOrigin.Dashboard), - Schema.Literal(CustomerOrigin.IOS), - Schema.Literal(CustomerOrigin.Android), - Schema.Literal(CustomerOrigin.Stripe), - Schema.Literal(CustomerOrigin.API) - ), + projectId: Schema.String, + appUserId: Schema.String, + name: Schema.NullishOr( + Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)) + ), + email: Schema.NullishOr(Schema.String), + origin: Schema.Union( + Schema.Literal(CustomerOrigin.Dashboard), + Schema.Literal(CustomerOrigin.IOS), + Schema.Literal(CustomerOrigin.Android), + Schema.Literal(CustomerOrigin.Stripe), + Schema.Literal(CustomerOrigin.API) + ) }); // Environments export const switchEnvironmentInputSchema = Schema.Struct({ - projectId: Schema.String, - environment: Schema.Union( - Schema.Literal(Environment.Production), - Schema.Literal(Environment.Testing) - ), + projectId: Schema.String, + environment: Schema.Union( + Schema.Literal(Environment.Production), + Schema.Literal(Environment.Testing) + ) }); // Organizations export const createOrganizationInputSchema = Schema.Struct({ - name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)), + name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)) }); export const updateOrganizationInputSchema = Schema.Struct({ - organizationId: Schema.String, - name: Schema.String, + organizationId: Schema.String, + name: Schema.String }); export const deleteOrganizationInputSchema = Schema.Struct({ - organizationId: Schema.String, + organizationId: Schema.String }); // Payment Providers export const createPaymentProviderConfigurationInputSchema = Schema.Struct({ - projectId: Schema.String, - providerId: Schema.String, + projectId: Schema.String, + providerId: Schema.String }); export const updatePaymentProviderConfigurationInputSchema = Schema.Struct({ - id: Schema.String, - enabled: Schema.Boolean, - name: Schema.optional( - Schema.String.pipe(Schema.minLength(1), Schema.maxLength(255)) - ), - configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + id: Schema.String, + enabled: Schema.Boolean, + name: Schema.optional( + Schema.String.pipe(Schema.minLength(1), Schema.maxLength(255)) + ), + configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }) }); export const deletePaymentProviderConfigurationInputSchema = Schema.Struct({ - paymentProviderConfigurationId: Schema.String, + paymentProviderConfigurationId: Schema.String }); // Paywall locations export const createPaywallLocationInputSchema = Schema.Struct({ - projectId: Schema.String, - name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)), - slug: Schema.String.pipe( - Schema.minLength(3), - Schema.maxLength(32), - Schema.pattern(/^[a-z0-9_-]+$/) - ), - defaultPaywallId: Schema.String, + projectId: Schema.String, + name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)), + slug: Schema.String.pipe( + Schema.minLength(3), + Schema.maxLength(32), + Schema.pattern(/^[a-z0-9_-]+$/) + ), + defaultPaywallId: Schema.String }); export const deletePaywallLocationInputSchema = Schema.Struct({ - paywallLocationId: Schema.String, + paywallLocationId: Schema.String }); // Paywalls export const createPaywallInputSchema = Schema.Struct({ - projectId: Schema.String, - name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)), + projectId: Schema.String, + name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)) }); export const updatePaywallInputSchema = Schema.Struct({ - paywallId: Schema.String, - name: Schema.optional(Schema.String.pipe(Schema.minLength(3))), - paywallProducts: Schema.Array( - Schema.Struct({ - productId: Schema.String.pipe(Schema.minLength(1)), - displayName: Schema.String.pipe(Schema.minLength(2)), - enableNativePurchase: Schema.Boolean, - enableWebCheckout: Schema.Boolean, - webCheckoutPaymentProviderConfigurationProductId: Schema.NullOr( - Schema.String - ), - order: Schema.Number, - }) - ), + paywallId: Schema.String, + name: Schema.optional(Schema.String.pipe(Schema.minLength(3))), + paywallProducts: Schema.Array( + Schema.Struct({ + productId: Schema.String.pipe(Schema.minLength(1)), + displayName: Schema.String.pipe(Schema.minLength(2)), + enableNativePurchase: Schema.Boolean, + enableWebCheckout: Schema.Boolean, + webCheckoutPaymentProviderConfigurationProductId: Schema.NullOr( + Schema.String + ), + order: Schema.Number + }) + ) }); export const deletePaywallInputSchema = Schema.Struct({ - paywallId: Schema.String, + paywallId: Schema.String }); // Perks export const createPerkInputSchema = Schema.Struct({ - projectId: Schema.String, - name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)), - slug: Schema.String.pipe( - Schema.minLength(3), - Schema.maxLength(32), - Schema.pattern(/^[a-z0-9_-]+$/) - ), + projectId: Schema.String, + name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)), + slug: Schema.String.pipe( + Schema.minLength(3), + Schema.maxLength(32), + Schema.pattern(/^[a-z0-9_-]+$/) + ) }); export const deletePerkInputSchema = Schema.Struct({ - perkId: Schema.String, + perkId: Schema.String }); // Products export const createProductInputSchema = Schema.Struct({ - projectId: Schema.String, - name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)), + projectId: Schema.String, + name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)) }); export const updateProductInputSchema = Schema.Struct({ - productId: Schema.String, - name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)), + productId: Schema.String, + name: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(32)) }); export const deleteProductInputSchema = Schema.Struct({ - productId: Schema.String, + productId: Schema.String }); // Product Perks export const createProductPerkInputSchema = Schema.Struct({ - productId: Schema.String, - perkId: Schema.String, + productId: Schema.String, + perkId: Schema.String }); export const deleteProductPerkInputSchema = Schema.Struct({ - productId: Schema.String, - perkId: Schema.String, + productId: Schema.String, + perkId: Schema.String }); // Payment Provider Configuration Products export const createPaymentProviderProductInputSchema = Schema.Struct({ - productId: Schema.String, - paymentProviderConfigurationId: Schema.String, - configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + productId: Schema.String, + paymentProviderConfigurationId: Schema.String, + configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }) }); export const updatePaymentProviderProductInputSchema = Schema.Struct({ - paymentProviderConfigurationProductId: Schema.String, - configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + paymentProviderConfigurationProductId: Schema.String, + configuration: Schema.Record({ key: Schema.String, value: Schema.Unknown }) }); export const setActivePaymentProviderProductInputSchema = Schema.Struct({ - productId: Schema.String, - providerProductKey: Schema.String, - paymentProviderConfigurationId: Schema.String, + productId: Schema.String, + providerProductKey: Schema.String, + paymentProviderConfigurationId: Schema.String }); export const deletePaymentProviderProductInputSchema = Schema.Struct({ - productId: Schema.String, - paymentProviderConfigurationId: Schema.String, - providerProductKey: Schema.String, + productId: Schema.String, + paymentProviderConfigurationId: Schema.String, + providerProductKey: Schema.String }); // Projects export const createProjectInputSchema = Schema.Struct({ - name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(32)), - organizationId: Schema.String, + name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(32)), + organizationId: Schema.String }); export const updateProjectInputSchema = Schema.Struct({ - id: Schema.String, - name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(32)), + id: Schema.String, + name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(32)) }); export const deleteProjectInputSchema = Schema.Struct({ - id: Schema.String, + id: Schema.String }); // SDK export const createCheckoutInputSchema = Schema.Struct({ - paymentProviderConfigurationProductId: Schema.String.pipe( - Schema.minLength(1) - ), - successCallbackUrl: Schema.String.pipe(Schema.minLength(1)), - errorCallbackUrl: Schema.String.pipe(Schema.minLength(1)), + paymentProviderConfigurationProductId: Schema.String.pipe( + Schema.minLength(1) + ), + successCallbackUrl: Schema.String.pipe(Schema.minLength(1)), + errorCallbackUrl: Schema.String.pipe(Schema.minLength(1)) }); export const getPaywallByLocationInputSchema = Schema.Struct({ - locationSlug: Schema.String, - nativePaymentProviderId: Schema.optional(Schema.String), + locationSlug: Schema.String, + nativePaymentProviderId: Schema.optional(Schema.String) }); export const identifyCustomerInputSchema = Schema.Struct({ - appUserId: Schema.String.pipe( - Schema.minLength(5), - Schema.filter( - (id) => - !ID_BLACKLIST.includes(id) && - !id.includes("/") && - !id.startsWith(ANONYMOUS_USER_ID_PREFIX), - { message: () => "Invalid app user ID" } - ) - ), - name: Schema.optional(Schema.String), - email: Schema.optional( - Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) - ), + appUserId: Schema.String.pipe( + Schema.minLength(5), + Schema.filter( + (id) => + !( + ID_BLACKLIST.includes(id) || + id.includes('/') || + id.startsWith(ANONYMOUS_USER_ID_PREFIX) + ), + { message: () => 'Invalid app user ID' } + ) + ), + name: Schema.optional(Schema.String), + email: Schema.optional( + Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) + ) }); diff --git a/apps/web/lib/nextjs/server-actions.ts b/apps/web/lib/nextjs/server-actions.ts index 43a4fb28f..8e3388ed7 100644 --- a/apps/web/lib/nextjs/server-actions.ts +++ b/apps/web/lib/nextjs/server-actions.ts @@ -1,1287 +1,1301 @@ -"use server"; - -import { actionClient } from "@/lib/safe-action"; +'use server'; +import { CustomerOrigin } from '@voidhash/db'; +import { Effect, pipe, Schema } from 'effect'; +import { actionClient } from '@/lib/safe-action'; +import { + NextjsErrorResponse, + runServerEffect +} from '../effect/runtimes/nextjs'; +import { cancelDevCheckoutPurchaseInputSchema } from '../payment-providers/dev-checkout/actions/cancel-purchase'; +import { confirmDevCheckoutPurchaseInputSchema } from '../payment-providers/dev-checkout/actions/confirm-purchase'; +import { DevCheckoutService } from '../payment-providers/dev-checkout/dev-checkout.service'; +import { ApiKeyService } from '../services/api-key.service'; +import { AuthService, AuthSession } from '../services/auth.service'; +import { CustomerService } from '../services/customer.service'; import { - NextjsErrorResponse, - runServerEffect, -} from "../effect/runtimes/nextjs"; -import { Effect, pipe, Schema } from "effect"; -import { PerkService } from "../services/perk.service"; -import { PaywallLocationService } from "../services/paywall-location.service"; -import { ApiKeyService } from "../services/api-key.service"; -import { CustomerService } from "../services/customer.service"; -import { DevCheckoutService } from "../payment-providers/dev-checkout/dev-checkout.service"; -import { confirmDevCheckoutPurchaseInputSchema } from "../payment-providers/dev-checkout/actions/confirm-purchase"; -import { cancelDevCheckoutPurchaseInputSchema } from "../payment-providers/dev-checkout/actions/cancel-purchase"; -import { CustomerOrigin } from "@voidhash/db"; -import { EnvironmentService } from "../services/environment.service"; + Environment, + EnvironmentService +} from '../services/environment.service'; +import { OrganizationService } from '../services/organization.service'; +import { PaymentProviderService } from '../services/payment-provider.service'; +import { PaywallService } from '../services/paywall.service'; +import { PaywallLocationService } from '../services/paywall-location.service'; +import { PerkService } from '../services/perk.service'; +import { ProductService } from '../services/product.service'; +import { ProjectService } from '../services/project.service'; import { - createSecretKeyInputSchema, - rotateSecretKeyInputSchema, - deleteSecretKeyInputSchema, - createOrganizationInputSchema, - updateOrganizationInputSchema, - deleteOrganizationInputSchema, - createProjectInputSchema, - updateProjectInputSchema, - deleteProjectInputSchema, - createPaymentProviderConfigurationInputSchema, - updatePaymentProviderConfigurationInputSchema, - deletePaymentProviderConfigurationInputSchema, - createProductInputSchema, - updateProductInputSchema, - deleteProductInputSchema, - createProductPerkInputSchema, - deleteProductPerkInputSchema, - createPaymentProviderProductInputSchema, - updatePaymentProviderProductInputSchema, - setActivePaymentProviderProductInputSchema, - deletePaymentProviderProductInputSchema, - createCustomerInputSchema, - createPaywallInputSchema, - updatePaywallInputSchema, - deletePaywallInputSchema, - createPaywallLocationInputSchema, - deletePaywallLocationInputSchema, - createPerkInputSchema, - deletePerkInputSchema, - switchEnvironmentInputSchema, -} from "./schema"; -import { OrganizationService } from "../services/organization.service"; -import { ProjectService } from "../services/project.service"; -import { PaymentProviderService } from "../services/payment-provider.service"; -import { ProductService } from "../services/product.service"; -import { PaywallService } from "../services/paywall.service"; -import { AuthService, AuthSession } from "../services/auth.service"; -import { Environment } from "../services/environment.service"; + createCustomerInputSchema, + createOrganizationInputSchema, + createPaymentProviderConfigurationInputSchema, + createPaymentProviderProductInputSchema, + createPaywallInputSchema, + createPaywallLocationInputSchema, + createPerkInputSchema, + createProductInputSchema, + createProductPerkInputSchema, + createProjectInputSchema, + createSecretKeyInputSchema, + deleteOrganizationInputSchema, + deletePaymentProviderConfigurationInputSchema, + deletePaymentProviderProductInputSchema, + deletePaywallInputSchema, + deletePaywallLocationInputSchema, + deletePerkInputSchema, + deleteProductInputSchema, + deleteProductPerkInputSchema, + deleteProjectInputSchema, + deleteSecretKeyInputSchema, + rotateSecretKeyInputSchema, + setActivePaymentProviderProductInputSchema, + switchEnvironmentInputSchema, + updateOrganizationInputSchema, + updatePaymentProviderConfigurationInputSchema, + updatePaymentProviderProductInputSchema, + updatePaywallInputSchema, + updateProductInputSchema, + updateProjectInputSchema +} from './schema'; // Api keys export const createSecretKeyAction = actionClient - .inputSchema(Schema.standardSchemaV1(createSecretKeyInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - projectId: parsedInput.projectId, - }); - return yield* Environment.provide(environment)( - apiKeyService.createSecretKey(parsedInput), - ); - }), - ); - }), - Effect.catchTags({ - ApiKeyNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(createSecretKeyInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + projectId: parsedInput.projectId + }); + return yield* Environment.provide(environment)( + apiKeyService.createSecretKey(parsedInput) + ); + }) + ); + }), + Effect.catchTags({ + ApiKeyNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const rotateSecretKeyAction = actionClient - .inputSchema(Schema.standardSchemaV1(rotateSecretKeyInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - apiKeyService.rotateSecretKey(parsedInput), - ); - }), - Effect.catchTags({ - ApiKeyNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(rotateSecretKeyInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + apiKeyService.rotateSecretKey(parsedInput) + ); + }), + Effect.catchTags({ + ApiKeyNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const deleteSecretKeyAction = actionClient - .inputSchema(Schema.standardSchemaV1(deleteSecretKeyInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - apiKeyService.deleteSecretKey(parsedInput), - ); - }), - Effect.catchTags({ - ApiKeyNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(deleteSecretKeyInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + apiKeyService.deleteSecretKey(parsedInput) + ); + }), + Effect.catchTags({ + ApiKeyNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Organization export const createOrganizationAction = actionClient - .inputSchema(Schema.standardSchemaV1(createOrganizationInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - organizationService.createOrganization(parsedInput), - ); - }), - Effect.catchTags({ - FailedToCreateOrganizationError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }), - ), - UserSessionNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(createOrganizationInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + organizationService.createOrganization(parsedInput) + ); + }), + Effect.catchTags({ + FailedToCreateOrganizationError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ), + UserSessionNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const updateOrganizationAction = actionClient - .inputSchema(Schema.standardSchemaV1(updateOrganizationInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - organizationService.updateOrganization(parsedInput), - ); - }), - Effect.catchTags({ - OrganizationNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(updateOrganizationInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + organizationService.updateOrganization(parsedInput) + ); + }), + Effect.catchTags({ + OrganizationNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const deleteOrganizationAction = actionClient - .inputSchema(Schema.standardSchemaV1(deleteOrganizationInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - organizationService.deleteOrganization(parsedInput), - ); - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(deleteOrganizationInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + organizationService.deleteOrganization(parsedInput) + ); + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Project export const createProjectAction = actionClient - .inputSchema(Schema.standardSchemaV1(createProjectInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - projectService.createProject(parsedInput), - ); - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(createProjectInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + projectService.createProject(parsedInput) + ); + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const updateProjectAction = actionClient - .inputSchema(Schema.standardSchemaV1(updateProjectInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - projectService.updateProject(parsedInput), - ); - }), - Effect.catchTags({ - ProjectNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(updateProjectInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + projectService.updateProject(parsedInput) + ); + }), + Effect.catchTags({ + ProjectNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const deleteProjectAction = actionClient - .inputSchema(Schema.standardSchemaV1(deleteProjectInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const projectService = yield* ProjectService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - projectService.deleteProject(parsedInput), - ); - }), - Effect.catchTags({ - ProjectNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(deleteProjectInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const projectService = yield* ProjectService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + projectService.deleteProject(parsedInput) + ); + }), + Effect.catchTags({ + ProjectNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Environment export const switchEnvironmentAction = actionClient - .inputSchema(Schema.standardSchemaV1(switchEnvironmentInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const environmentService = yield* EnvironmentService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - environmentService.switchEnvironment(parsedInput), - ); - }), - Effect.catchTags({ - ProjectNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - OrganizationNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - OrganizationWithoutSlugError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(switchEnvironmentInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const environmentService = yield* EnvironmentService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + environmentService.switchEnvironment(parsedInput) + ); + }), + Effect.catchTags({ + ProjectNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ), + OrganizationNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ), + OrganizationWithoutSlugError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Payment providers export const createPaymentProviderConfigurationAction = actionClient - .inputSchema( - Schema.standardSchemaV1(createPaymentProviderConfigurationInputSchema), - ) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const paymentProviderService = yield* PaymentProviderService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - paymentProviderService.createPaymentProviderConfiguration( - parsedInput, - ), - ); - }), - Effect.catchTags({ - PaymentProviderNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - PaymentProviderAlreadyExistsError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema( + Schema.standardSchemaV1(createPaymentProviderConfigurationInputSchema) + ) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const paymentProviderService = yield* PaymentProviderService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + paymentProviderService.createPaymentProviderConfiguration( + parsedInput + ) + ); + }), + Effect.catchTags({ + PaymentProviderNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ), + PaymentProviderAlreadyExistsError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const updatePaymentProviderConfigurationAction = actionClient - .inputSchema( - Schema.standardSchemaV1(updatePaymentProviderConfigurationInputSchema), - ) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const paymentProviderService = yield* PaymentProviderService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - paymentProviderService.updatePaymentProviderConfiguration( - parsedInput, - ), - ); - }), - Effect.catchTags({ - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - PaymentProviderNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - ValidationError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - PaymentProviderKeyUnavailableError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema( + Schema.standardSchemaV1(updatePaymentProviderConfigurationInputSchema) + ) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const paymentProviderService = yield* PaymentProviderService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + paymentProviderService.updatePaymentProviderConfiguration( + parsedInput + ) + ); + }), + Effect.catchTags({ + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ), + PaymentProviderNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ), + ValidationError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + PaymentProviderKeyUnavailableError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const deletePaymentProviderConfigurationAction = actionClient - .inputSchema( - Schema.standardSchemaV1(deletePaymentProviderConfigurationInputSchema), - ) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const paymentProviderService = yield* PaymentProviderService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - paymentProviderService.deletePaymentProviderConfiguration( - parsedInput, - ), - ); - }), - Effect.catchTags({ - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema( + Schema.standardSchemaV1(deletePaymentProviderConfigurationInputSchema) + ) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const paymentProviderService = yield* PaymentProviderService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + paymentProviderService.deletePaymentProviderConfiguration( + parsedInput + ) + ); + }), + Effect.catchTags({ + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Products export const createProductAction = actionClient - .inputSchema(Schema.standardSchemaV1(createProductInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const productService = yield* ProductService; - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - projectId: parsedInput.projectId, - }); - return yield* Environment.provide(environment)( - productService.createProduct(parsedInput), - ); - }), - ); - }), - Effect.catchTags({ - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "INTERNAL_SERVER_ERROR", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(createProductInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const productService = yield* ProductService; + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + projectId: parsedInput.projectId + }); + return yield* Environment.provide(environment)( + productService.createProduct(parsedInput) + ); + }) + ); + }), + Effect.catchTags({ + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const updateProductAction = actionClient - .inputSchema(Schema.standardSchemaV1(updateProductInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const productService = yield* ProductService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - productService.updateProduct(parsedInput), - ); - }), - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(updateProductInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const productService = yield* ProductService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + productService.updateProduct(parsedInput) + ); + }), + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const deleteProductAction = actionClient - .inputSchema(Schema.standardSchemaV1(deleteProductInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const productService = yield* ProductService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - productService.deleteProduct(parsedInput), - ); - }), - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(deleteProductInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const productService = yield* ProductService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + productService.deleteProduct(parsedInput) + ); + }), + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Product perks export const createProductPerkAction = actionClient - .inputSchema(Schema.standardSchemaV1(createProductPerkInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const productService = yield* ProductService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - productService.createProductPerk(parsedInput), - ); - }), - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - PerkNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(createProductPerkInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const productService = yield* ProductService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + productService.createProductPerk(parsedInput) + ); + }), + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + PerkNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const deleteProductPerkAction = actionClient - .inputSchema(Schema.standardSchemaV1(deleteProductPerkInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const productService = yield* ProductService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - productService.deleteProductPerk(parsedInput), - ); - }), - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(deleteProductPerkInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const productService = yield* ProductService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + productService.deleteProductPerk(parsedInput) + ); + }), + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Payment provider products export const createPaymentProviderProductAction = actionClient - .inputSchema(Schema.standardSchemaV1(createPaymentProviderProductInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const productService = yield* ProductService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - productService.createPaymentProviderProduct(parsedInput), - ); - }), - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - PaymentProviderNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - InvalidConfiguration: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(createPaymentProviderProductInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const productService = yield* ProductService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + productService.createPaymentProviderProduct(parsedInput) + ); + }), + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + PaymentProviderNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + InvalidConfiguration: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const updatePaymentProviderProductAction = actionClient - .inputSchema(Schema.standardSchemaV1(updatePaymentProviderProductInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const productService = yield* ProductService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - productService.updatePaymentProviderProduct(parsedInput), - ); - }), - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - PaymentProviderNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - ProviderProductNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - InvalidConfiguration: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(updatePaymentProviderProductInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const productService = yield* ProductService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + productService.updatePaymentProviderProduct(parsedInput) + ); + }), + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + PaymentProviderNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + ProviderProductNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + InvalidConfiguration: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const setActivePaymentProviderProductAction = actionClient - .inputSchema( - Schema.standardSchemaV1(setActivePaymentProviderProductInputSchema), - ) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const productService = yield* ProductService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - productService.setActivePaymentProviderProduct(parsedInput), - ); - }), - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - PaymentProviderNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema( + Schema.standardSchemaV1(setActivePaymentProviderProductInputSchema) + ) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const productService = yield* ProductService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + productService.setActivePaymentProviderProduct(parsedInput) + ); + }), + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + PaymentProviderNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const deletePaymentProviderProductAction = actionClient - .inputSchema(Schema.standardSchemaV1(deletePaymentProviderProductInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const productService = yield* ProductService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - productService.deletePaymentProviderProduct(parsedInput), - ); - }), - Effect.catchTags({ - ProductNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(deletePaymentProviderProductInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const productService = yield* ProductService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + productService.deletePaymentProviderProduct(parsedInput) + ); + }), + Effect.catchTags({ + ProductNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Customers export const createCustomerAction = actionClient - .inputSchema( - Schema.standardSchemaV1(createCustomerInputSchema.omit("origin")), - ) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - projectId: parsedInput.projectId, - }); - return yield* Environment.provide(environment)( - customerService.createCustomer({ - ...parsedInput, - origin: CustomerOrigin.Dashboard, - }), - ); - }), - ); - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema( + Schema.standardSchemaV1(createCustomerInputSchema.omit('origin')) + ) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + projectId: parsedInput.projectId + }); + return yield* Environment.provide(environment)( + customerService + .createCustomer({ + ...parsedInput, + origin: CustomerOrigin.Dashboard, + environment + }) + .pipe( + Effect.catchTags({ + InvalidAnonymousIdError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + }) + ); + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Paywalls export const createPaywallAction = actionClient - .inputSchema(Schema.standardSchemaV1(createPaywallInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - projectId: parsedInput.projectId, - }); - return yield* Environment.provide(environment)( - paywallService.createPaywall(parsedInput), - ); - }), - ); - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(createPaywallInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + projectId: parsedInput.projectId + }); + return yield* Environment.provide(environment)( + paywallService.createPaywall(parsedInput) + ); + }) + ); + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const updatePaywallAction = actionClient - .inputSchema(Schema.standardSchemaV1(updatePaywallInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - paywallService.updatePaywall({ - ...parsedInput, - paywallProducts: [...parsedInput.paywallProducts], - }), - ); - }), - Effect.catchTags({ - PaywallNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - ProductNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - PaymentProviderConfigurationNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(updatePaywallInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + paywallService.updatePaywall({ + ...parsedInput, + paywallProducts: [...parsedInput.paywallProducts] + }) + ); + }), + Effect.catchTags({ + PaywallNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ), + ProductNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + PaymentProviderConfigurationNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const deletePaywallAction = actionClient - .inputSchema(Schema.standardSchemaV1(deletePaywallInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - paywallService.deletePaywall(parsedInput), - ); - }), - Effect.catchTags({ - PaywallNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - PaywallInUseError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(deletePaywallInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + paywallService.deletePaywall(parsedInput) + ); + }), + Effect.catchTags({ + PaywallNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ), + PaywallInUseError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Paywall locations export const createPaywallLocationAction = actionClient - .inputSchema(Schema.standardSchemaV1(createPaywallLocationInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const paywallLocationService = yield* PaywallLocationService; - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - projectId: parsedInput.projectId, - }); - return yield* Environment.provide(environment)( - paywallLocationService.createPaywallLocation(parsedInput), - ); - }), - ); - }), - Effect.catchTags({ - SlugAlreadyExistsError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - DefaultPaywallNotFoundError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(createPaywallLocationInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + projectId: parsedInput.projectId + }); + return yield* Environment.provide(environment)( + paywallLocationService.createPaywallLocation(parsedInput) + ); + }) + ); + }), + Effect.catchTags({ + SlugAlreadyExistsError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ), + DefaultPaywallNotFoundError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const deletePaywallLocationAction = actionClient - .inputSchema(Schema.standardSchemaV1(deletePaywallLocationInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const paywallLocationService = yield* PaywallLocationService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - paywallLocationService.deletePaywallLocation({ - paywallLocationId: parsedInput.paywallLocationId, - }), - ); - }), - Effect.catchTags({ - PaywallLocationNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(deletePaywallLocationInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + paywallLocationService.deletePaywallLocation({ + paywallLocationId: parsedInput.paywallLocationId + }) + ); + }), + Effect.catchTags({ + PaywallLocationNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Perks export const createPerkAction = actionClient - .inputSchema(Schema.standardSchemaV1(createPerkInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const perkService = yield* PerkService; - const authService = yield* AuthService; - const environmentService = yield* EnvironmentService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - Effect.gen(function* () { - const environment = - yield* environmentService.getEnvironmentFromCookie({ - projectId: parsedInput.projectId, - }); - return yield* Environment.provide(environment)( - perkService.createPerk(parsedInput), - ); - }), - ); - }), - Effect.catchTags({ - SlugAlreadyExistsError: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(createPerkInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const perkService = yield* PerkService; + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromCookie({ + projectId: parsedInput.projectId + }); + return yield* Environment.provide(environment)( + perkService.createPerk(parsedInput) + ); + }) + ); + }), + Effect.catchTags({ + SlugAlreadyExistsError: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const deletePerkAction = actionClient - .inputSchema(Schema.standardSchemaV1(deletePerkInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - Effect.gen(function* () { - const perkService = yield* PerkService; - const authService = yield* AuthService; - const authSession = yield* authService.authenticateWithSession(); - return yield* AuthSession.provide(authSession)( - perkService.deletePerk({ perkId: parsedInput.perkId }), - ); - }), - Effect.catchTags({ - PerkNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(deletePerkInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + Effect.gen(function* () { + const perkService = yield* PerkService; + const authService = yield* AuthService; + const authSession = yield* authService.authenticateWithSession(); + return yield* AuthSession.provide(authSession)( + perkService.deletePerk({ perkId: parsedInput.perkId }) + ); + }), + Effect.catchTags({ + PerkNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); // Dev checkout export const confirmDevCheckoutPurchaseAction = actionClient - .inputSchema(Schema.standardSchemaV1(confirmDevCheckoutPurchaseInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - DevCheckoutService, - Effect.flatMap((devCheckoutService) => - devCheckoutService.confirmPurchase(parsedInput), - ), - Effect.catchTags({ - CheckoutSessionNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - CheckoutSessionWasAlreadyCancelled: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(confirmDevCheckoutPurchaseInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + DevCheckoutService, + Effect.flatMap((devCheckoutService) => + devCheckoutService.confirmPurchase(parsedInput) + ), + Effect.catchTags({ + CheckoutSessionNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ), + CheckoutSessionWasAlreadyCancelled: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); export const cancelDevCheckoutPurchaseAction = actionClient - .inputSchema(Schema.standardSchemaV1(cancelDevCheckoutPurchaseInputSchema)) - .action(async ({ parsedInput }) => { - const res = await runServerEffect( - pipe( - DevCheckoutService, - Effect.flatMap((devCheckoutService) => - devCheckoutService.cancelPurchase(parsedInput), - ), - Effect.catchTags({ - CheckoutSessionNotFound: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "NOT_FOUND", - message: error.message, - }), - ), - CheckoutSessionWasAlreadyConfirmed: (error) => - Effect.fail( - new NextjsErrorResponse({ - code: "BAD_REQUEST", - message: error.message, - }), - ), - }), - ), - ); - - if (res.isErr()) { - throw res.error; - } - - return res.value; - }); + .inputSchema(Schema.standardSchemaV1(cancelDevCheckoutPurchaseInputSchema)) + .action(async ({ parsedInput }) => { + const res = await runServerEffect( + pipe( + DevCheckoutService, + Effect.flatMap((devCheckoutService) => + devCheckoutService.cancelPurchase(parsedInput) + ), + Effect.catchTags({ + CheckoutSessionNotFound: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'NOT_FOUND', + message: error.message + }) + ), + CheckoutSessionWasAlreadyConfirmed: (error) => + Effect.fail( + new NextjsErrorResponse({ + code: 'BAD_REQUEST', + message: error.message + }) + ) + }) + ) + ); + + if (res.isErr()) { + throw res.error; + } + + return res.value; + }); diff --git a/apps/web/lib/nextjs/utils/next-cookies-adapter.ts b/apps/web/lib/nextjs/utils/next-cookies-adapter.ts index 7ceb4f57c..d4a39f929 100644 --- a/apps/web/lib/nextjs/utils/next-cookies-adapter.ts +++ b/apps/web/lib/nextjs/utils/next-cookies-adapter.ts @@ -1,16 +1,16 @@ -import { cookies } from "next/headers"; -import { CookiesAdapter } from "../../cookies-adapter"; +import { cookies } from 'next/headers'; +import type { CookiesAdapter } from '../../cookies-adapter'; export class NextCookiesAdapter implements CookiesAdapter { - async get(name: string): Promise { - return (await cookies()).get(name)?.value ?? null; - } + async get(name: string): Promise { + return (await cookies()).get(name)?.value ?? null; + } - async set(name: string, value: string): Promise { - (await cookies()).set(name, value); - } + async set(name: string, value: string): Promise { + (await cookies()).set(name, value); + } - async delete(name: string): Promise { - (await cookies()).delete(name); - } + async delete(name: string): Promise { + (await cookies()).delete(name); + } } diff --git a/apps/web/lib/nextjs/utils/next-middleware-cookie-adapter.ts b/apps/web/lib/nextjs/utils/next-middleware-cookie-adapter.ts index 6cd990c03..277fcab93 100644 --- a/apps/web/lib/nextjs/utils/next-middleware-cookie-adapter.ts +++ b/apps/web/lib/nextjs/utils/next-middleware-cookie-adapter.ts @@ -1,20 +1,24 @@ -import { CookiesAdapter } from "@/lib/cookies-adapter"; -import { NextRequest } from "next/server"; +import type { NextRequest } from 'next/server'; +import type { CookiesAdapter } from '@/lib/cookies-adapter'; export class NextMiddlewareCookiesAdapter implements CookiesAdapter { - constructor(private readonly req: NextRequest) {} + private readonly req: NextRequest; + constructor(req: NextRequest) { + this.req = req; + } - async get(name: string): Promise { - return this.req.cookies.get(name)?.value ?? null; - } + // biome-ignore lint/suspicious/useAwait: need to match CookiesAdapter interface + async get(name: string): Promise { + return this.req.cookies.get(name)?.value ?? null; + } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async set(_: string, __: string): Promise { - throw new Error("Settings cookies is not allowed in next.js middleware"); - } + // biome-ignore lint/suspicious/useAwait: need to match CookiesAdapter interface + async set(_: string, __: string): Promise { + throw new Error('Settings cookies is not allowed in next.js middleware'); + } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async delete(_: string): Promise { - throw new Error("Deleting cookies is not allowed in next.js middleware"); - } + // biome-ignore lint/suspicious/useAwait: need to match CookiesAdapter interface + async delete(_: string): Promise { + throw new Error('Deleting cookies is not allowed in next.js middleware'); + } } diff --git a/apps/web/lib/nextjs/utils/next-unstable-cache-adapter.ts b/apps/web/lib/nextjs/utils/next-unstable-cache-adapter.ts index 460264d7a..d40037933 100644 --- a/apps/web/lib/nextjs/utils/next-unstable-cache-adapter.ts +++ b/apps/web/lib/nextjs/utils/next-unstable-cache-adapter.ts @@ -1,26 +1,26 @@ -import { revalidateTag, unstable_cache } from "next/cache"; -import { CacheAdapter } from "../../cache-adapter"; +import { revalidateTag, unstable_cache } from 'next/cache'; +import type { CacheAdapter } from '../../cache-adapter'; export class NextUnstableCacheAdapter implements CacheAdapter { - /** - * Adapts Next.js unstable_cache to the CacheAdapter interface - */ - cacheFn( - fn: (...args: TArgs) => Promise, - keys: string[], - options?: { - tags?: string[]; - revalidate?: number; - } - ): (...args: TArgs) => Promise { - return unstable_cache(fn, keys, options); - } + /** + * Adapts Next.js unstable_cache to the CacheAdapter interface + */ + cacheFn( + fn: (...args: TArgs) => Promise, + keys: string[], + options?: { + tags?: string[]; + revalidate?: number; + } + ): (...args: TArgs) => Promise { + return unstable_cache(fn, keys, options); + } - /** - * Invalidate the cache for a given key - * @param key - The key to invalidate - */ - invalidate(key: string): void { - revalidateTag(key); - } + /** + * Invalidate the cache for a given key + * @param key - The key to invalidate + */ + invalidate(key: string): void { + revalidateTag(key); + } } diff --git a/apps/web/lib/payment-providers/app-store/api/app-store_validateTransaction.ts b/apps/web/lib/payment-providers/app-store/api/app-store_validateTransaction.ts index dbcd78a51..bbc08e9f9 100644 --- a/apps/web/lib/payment-providers/app-store/api/app-store_validateTransaction.ts +++ b/apps/web/lib/payment-providers/app-store/api/app-store_validateTransaction.ts @@ -1,80 +1,106 @@ -// import { describeRoute } from "hono-openapi"; -// import { resolver } from "hono-openapi/zod"; -// import { App } from "@/lib/api/hono/app"; -// import { authenticateContext } from "@/lib/service-function"; -// import { z } from "zod"; -// import { -// parseISO4217CurrencyCode, -// toVoidhashHTTPError, -// VoidhashBadRequestError, -// VoidhashNotFoundError, -// } from "@voidhash/lib/constants"; -// import { zValidator } from "@hono/zod-validator"; -// import { -// db, -// paymentProviderConfigurations, -// and, -// eq, -// paymentProviderConfigurationProducts, -// desc, -// appStoreTransactions, -// Transaction, -// } from "@voidhash/db"; -// import { openApiErrorResponses } from "@/lib/api/errors/openapi_responses"; -// import { appStore, appStorePaymentProviderId } from "../app-store"; -// import { -// AppStoreServerAPI, -// Environment, -// TransactionReason, -// TransactionType, -// decodeTransaction, -// } from "app-store-server-api"; -// import { safeTryPromise } from "@/lib/neverthrow"; -// import { ok } from "neverthrow"; -// import { processSubscriptionCreation } from "../../core/process-subscription-creation"; -// import { -// fromEnvironment, -// fromOfferDiscountType, -// fromOfferType, -// fromOwnershipType, -// fromRevocationReason, -// fromTransactionReason, -// fromTransactionType, -// } from "../utils"; -// import { generateId } from "@/lib/id/generate"; - -// const appStoreValidateTransactionBodySchema = z.object({ -// transactionId: z.string(), -// bundleId: z.string(), -// }); - -// const appStoreValidateTransactionResponseSchema = z.object({ -// success: z.boolean(), -// }); - -// const route = describeRoute({ -// description: "Validates a transaction", -// operationId: "appStoreValidateTransaction", -// security: [ -// { -// publishableKey: [], -// }, -// ], -// responses: { -// 200: { -// description: "Successful response", -// content: { -// "application/json": { -// schema: resolver(appStoreValidateTransactionResponseSchema), -// }, -// }, -// }, -// ...openApiErrorResponses, -// }, -// tags: ["App Store"], -// }); - -// export type Route = typeof route; +import { zValidator } from '@hono/zod-validator'; +import { Effect } from 'effect'; +import { describeRoute } from 'hono-openapi'; +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; +import { openApiErrorResponses } from '@/lib/api/errors/openapi_responses'; +import type { App } from '@/lib/api/hono/app'; +import { + createEffectHandler, + HonoErrorResponse +} from '@/lib/effect/runtimes/hono'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { + Environment, + EnvironmentService +} from '@/lib/services/environment.service'; +import { AppStoreService } from '../services/app-store.service'; + +const appStoreValidateTransactionBodySchema = z.object({ + transactionId: z.string(), + bundleId: z.string() +}); + +const appStoreValidateTransactionResponseSchema = z.object({ + success: z.boolean() +}); + +const route = describeRoute({ + description: 'Validates a transaction', + operationId: 'appStoreValidateTransaction', + security: [ + { + publishableKey: [] + } + ], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: resolver(appStoreValidateTransactionResponseSchema) + } + } + }, + ...openApiErrorResponses + }, + tags: ['App Store'] +}); + +export type Route = typeof route; + +export const registerAppStoreValidateTransaction = (app: App) => + app.post( + '/v1/app-store/validate-transaction', + route, + zValidator('json', appStoreValidateTransactionBodySchema), + async (c) => + createEffectHandler(c)( + Effect.gen(function* () { + const authService = yield* AuthService; + const environmentService = yield* EnvironmentService; + const appStoreService = yield* AppStoreService; + const authSession = + yield* authService.authenticateWithPublishableKey(); + return yield* AuthSession.provide(authSession)( + Effect.gen(function* () { + const environment = + yield* environmentService.getEnvironmentFromApiAuthSession(); + return yield* Environment.provide(environment)( + Effect.gen(function* () { + yield* appStoreService + .validateTransaction({ + transactionId: c.req.valid('json').transactionId, + bundleId: c.req.valid('json').bundleId, + environment + }) + .pipe( + // TODO: Properly handle errors + Effect.catchAll((error) => { + return Effect.gen(function* () { + return yield* Effect.fail( + new HonoErrorResponse({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to validate transaction', + originalError: error + }) + ); + }); + }) + ); + + return c.json< + z.infer + >({ + success: true + }); + }) + ); + }) + ); + }) + ) + ); // export const registerAppStoreValidateTransaction = (app: App) => // app.post( diff --git a/apps/web/lib/payment-providers/app-store/app-store-api.ts b/apps/web/lib/payment-providers/app-store/app-store-api.ts index 27184c77f..b28da3462 100644 --- a/apps/web/lib/payment-providers/app-store/app-store-api.ts +++ b/apps/web/lib/payment-providers/app-store/app-store-api.ts @@ -1,10 +1,8 @@ -import { createPaymentProviderApi } from "@/lib/core/payment-providers/payment-provider-api"; -import { App } from "@/lib/api/hono/app"; -// import { registerAppStoreValidateTransaction } from "./api/app-store_validateTransaction"; - +import type { App } from '@/lib/api/hono/app'; +import { createPaymentProviderApi } from '@/lib/core/payment-providers/payment-provider-api'; +import { registerAppStoreValidateTransaction } from './api/app-store_validateTransaction'; export const appStoreApi = createPaymentProviderApi({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - registerEndpoints: (app: App) => { - // registerAppStoreValidateTransaction(app); - }, + registerEndpoints: (app: App) => { + registerAppStoreValidateTransaction(app); + } }); diff --git a/apps/web/lib/payment-providers/app-store/app-store.ts b/apps/web/lib/payment-providers/app-store/app-store.ts index f5c1658d4..c1cdcaaa5 100644 --- a/apps/web/lib/payment-providers/app-store/app-store.ts +++ b/apps/web/lib/payment-providers/app-store/app-store.ts @@ -1,156 +1,156 @@ -import { z } from "zod"; -import { BasePaymentProvider } from "../../core/payment-providers/base-payment-provider"; -import { PaymentProvider } from "../../core/payment-providers/payment-provider"; -import { - PaymentProviderConfigurationSheetSection, - PaymentProviderProductEditorSheetSection, -} from "../../core/payment-providers/types"; -import { Environment } from "@voidhash/lib/index"; +import { Environment } from '@voidhash/lib/index'; +import { z } from 'zod'; +import { BasePaymentProvider } from '../../core/payment-providers/base-payment-provider'; +import type { PaymentProvider } from '../../core/payment-providers/payment-provider'; +import type { + PaymentProviderConfigurationSheetSection, + PaymentProviderProductEditorSheetSection +} from '../../core/payment-providers/types'; -export const appStorePaymentProviderId = "app-store" as const; +export const appStorePaymentProviderId = 'app-store' as const; const appStoreGlobalConfigurationSchema = z.object({ - issuerId: z.string().min(1, { - message: "Issuer ID is required", - }), - bundleId: z.string().min(1, { - message: "Bundle ID is required", - }), - keyId: z.string().min(1, { - message: "Key ID is required", - }), - privateKey: z.string().min(1, { - message: "Private key is required", - }), + issuerId: z.string().min(1, { + message: 'Issuer ID is required' + }), + bundleId: z.string().min(1, { + message: 'Bundle ID is required' + }), + keyId: z.string().min(1, { + message: 'Key ID is required' + }), + privateKey: z.string().min(1, { + message: 'Private key is required' + }) }); const appStoreProductConfigurationSchema = z.object({ - productId: z.string().min(1, { - message: "Product ID is required", - }), + productId: z.string().min(1, { + message: 'Product ID is required' + }) }); export class AppStorePaymentProvider - extends BasePaymentProvider< - typeof appStorePaymentProviderId, - typeof appStoreGlobalConfigurationSchema, - typeof appStoreProductConfigurationSchema - > - implements - PaymentProvider< - typeof appStorePaymentProviderId, - typeof appStoreGlobalConfigurationSchema, - typeof appStoreProductConfigurationSchema - > + extends BasePaymentProvider< + typeof appStorePaymentProviderId, + typeof appStoreGlobalConfigurationSchema, + typeof appStoreProductConfigurationSchema + > + implements + PaymentProvider< + typeof appStorePaymentProviderId, + typeof appStoreGlobalConfigurationSchema, + typeof appStoreProductConfigurationSchema + > { - constructor() { - super( - appStorePaymentProviderId, - "App Store", - [Environment.Production], - ["bundleId"] as const, - ["productId"] as const, - "native" - ); - } - getIsConfigurable(): boolean { - return true; - } - getDefaultGlobalConfiguration(): Partial< - z.infer - > { - return { - issuerId: "", - bundleId: "", - keyId: "", - privateKey: "", - }; - } - getGlobalConfigurationSchema(): typeof appStoreGlobalConfigurationSchema { - return appStoreGlobalConfigurationSchema; - } - getGlobalConfigurationSheet(): { - sections: PaymentProviderConfigurationSheetSection[]; - } { - return { - sections: [ - { - key: "bundleId", - type: "text-input", - name: "bundleId", - label: "Bundle ID", - input: { - type: "text", - placeholder: "com.example.app", - }, - }, - { - key: "issuerId", - type: "text-input", - name: "issuerId", - label: "Issuer ID", - input: { - type: "text", - placeholder: "00000000-0000-0000-0000-000000000000", - }, - }, - { - key: "keyId", - type: "text-input", - name: "keyId", - label: "Key ID", - input: { - type: "text", - placeholder: "XXXXXXXXXX", - }, - }, - { - key: "privateKey", - name: "privateKey", - type: "p8-upload", - label: "Private Key (.p8 file)", - successMessage: "Private key was successfully attached", - }, - ], - }; - } - getIsProductConfigurable(): boolean { - return true; - } - getDefaultProductConfiguration(): Partial< - z.infer - > { - return { - productId: "", - }; - } - getProductConfigurationSchema(): typeof appStoreProductConfigurationSchema { - return appStoreProductConfigurationSchema; - } - getProductConfigurationSheet(): { - sections: PaymentProviderProductEditorSheetSection[]; - } { - return { - sections: [ - { - key: "productId", - type: "text-input", - name: "productId", - label: "Product ID", - input: { - type: "text", - placeholder: "example_app.1_month_subscription", - }, - }, - ], - }; - } + constructor() { + super( + appStorePaymentProviderId, + 'App Store', + [Environment.Production], + ['bundleId'] as const, + ['productId'] as const, + 'native' + ); + } + getIsConfigurable(): boolean { + return true; + } + getDefaultGlobalConfiguration(): Partial< + z.infer + > { + return { + issuerId: '', + bundleId: '', + keyId: '', + privateKey: '' + }; + } + getGlobalConfigurationSchema(): typeof appStoreGlobalConfigurationSchema { + return appStoreGlobalConfigurationSchema; + } + getGlobalConfigurationSheet(): { + sections: PaymentProviderConfigurationSheetSection[]; + } { + return { + sections: [ + { + key: 'bundleId', + type: 'text-input', + name: 'bundleId', + label: 'Bundle ID', + input: { + type: 'text', + placeholder: 'com.example.app' + } + }, + { + key: 'issuerId', + type: 'text-input', + name: 'issuerId', + label: 'Issuer ID', + input: { + type: 'text', + placeholder: '00000000-0000-0000-0000-000000000000' + } + }, + { + key: 'keyId', + type: 'text-input', + name: 'keyId', + label: 'Key ID', + input: { + type: 'text', + placeholder: 'XXXXXXXXXX' + } + }, + { + key: 'privateKey', + name: 'privateKey', + type: 'p8-upload', + label: 'Private Key (.p8 file)', + successMessage: 'Private key was successfully attached' + } + ] + }; + } + getIsProductConfigurable(): boolean { + return true; + } + getDefaultProductConfiguration(): Partial< + z.infer + > { + return { + productId: '' + }; + } + getProductConfigurationSchema(): typeof appStoreProductConfigurationSchema { + return appStoreProductConfigurationSchema; + } + getProductConfigurationSheet(): { + sections: PaymentProviderProductEditorSheetSection[]; + } { + return { + sections: [ + { + key: 'productId', + type: 'text-input', + name: 'productId', + label: 'Product ID', + input: { + type: 'text', + placeholder: 'example_app.1_month_subscription' + } + } + ] + }; + } - checkIfCorrectlyConfigured( - // configuration: z.infer - ): boolean { - return true; - } + checkIfCorrectlyConfigured( + // configuration: z.infer + ): boolean { + return true; + } } export const appStore = new AppStorePaymentProvider(); diff --git a/apps/web/lib/payment-providers/app-store/constants.ts b/apps/web/lib/payment-providers/app-store/constants.ts new file mode 100644 index 000000000..676ae4f53 --- /dev/null +++ b/apps/web/lib/payment-providers/app-store/constants.ts @@ -0,0 +1,74 @@ +export const APPLE_ROOT_CERTIFICATE = + `MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzET +MBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0 +MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBw +bGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx +FjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg+ ++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1 +XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9w +tj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IW +q6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKM +aLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3 +R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAE +ggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93 +d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNl +IG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0 +YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBj +b25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZp +Y2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBc +NplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQP +y3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7 +R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4Fg +xhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oP +IQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AX +UKqK1drk/NAJBzewdXUh`.trim(); + +export const APPLE_ROOT_CA_G2 = + `MIIFkjCCA3qgAwIBAgIIAeDltYNno+AwDQYJKoZIhvcNAQEMBQAwZzEbMBkGA1UE +AwwSQXBwbGUgUm9vdCBDQSAtIEcyMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0 +aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMw +HhcNMTQwNDMwMTgxMDA5WhcNMzkwNDMwMTgxMDA5WjBnMRswGQYDVQQDDBJBcHBs +ZSBSb290IENBIC0gRzIxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0 +aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgREkhI2imKScUcx+xuM23+TfvgHN6s +XuI2pyT5f1BrTM65MFQn5bPW7SXmMLYFN14UIhHF6Kob0vuy0gmVOKTvKkmMXT5x +ZgM4+xb1hYjkWpIMBDLyyED7Ul+f9sDx47pFoFDVEovy3d6RhiPw9bZyLgHaC/Yu +OQhfGaFjQQscp5TBhsRTL3b2CtcM0YM/GlMZ81fVJ3/8E7j4ko380yhDPLVoACVd +J2LT3VXdRCCQgzWTxb+4Gftr49wIQuavbfqeQMpOhYV4SbHXw8EwOTKrfl+q04tv +ny0aIWhwZ7Oj8ZhBbZF8+NfbqOdfIRqMM78xdLe40fTgIvS/cjTf94FNcX1RoeKz +8NMoFnNvzcytN31O661A4T+B/fc9Cj6i8b0xlilZ3MIZgIxbdMYs0xBTJh0UT8TU +gWY8h2czJxQI6bR3hDRSj4n4aJgXv8O7qhOTH11UL6jHfPsNFL4VPSQ08prcdUFm +IrQB1guvkJ4M6mL4m1k8COKWNORj3rw31OsMiANDC1CvoDTdUE0V+1ok2Az6DGOe +HwOx4e7hqkP0ZmUoNwIx7wHHHtHMn23KVDpA287PT0aLSmWaasZobNfMmRtHsHLD +d4/E92GcdB/O/WuhwpyUgquUoue9G7q5cDmVF8Up8zlYNPXEpMZ7YLlmQ1A/bmH8 +DvmGqmAMQ0uVAgMBAAGjQjBAMB0GA1UdDgQWBBTEmRNsGAPCe8CjoA1/coB6HHcm +jTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwF +AAOCAgEAUabz4vS4PZO/Lc4Pu1vhVRROTtHlznldgX/+tvCHM/jvlOV+3Gp5pxy+ +8JS3ptEwnMgNCnWefZKVfhidfsJxaXwU6s+DDuQUQp50DhDNqxq6EWGBeNjxtUVA +eKuowM77fWM3aPbn+6/Gw0vsHzYmE1SGlHKy6gLti23kDKaQwFd1z4xCfVzmMX3z +ybKSaUYOiPjjLUKyOKimGY3xn83uamW8GrAlvacp/fQ+onVJv57byfenHmOZ4VxG +/5IFjPoeIPmGlFYl5bRXOJ3riGQUIUkhOb9iZqmxospvPyFgxYnURTbImHy99v6Z +SYA7LNKmp4gDBDEZt7Y6YUX6yfIjyGNzv1aJMbDZfGKnexWoiIqrOEDCzBL/FePw +N983csvMmOa/orz6JopxVtfnJBtIRD6e/J/JzBrsQzwBvDR4yGn1xuZW7AYJNpDr +FEobXsmII9oDMJELuDY++ee1KG++P+w8j2Ud5cAeh6Squpj9kuNsJnfdBrRkBof0 +Tta6SqoWqPQFZ2aWuuJVecMsXUmPgEkrihLHdoBR37q9ZV0+N0djMenl9MU/S60E +inpxLK8JQzcPqOMyT/RFtm2XNuyE9QoB6he7hY1Ck3DDUOUUi78/w0EP3SIEIwiK +um1xRKtzCTrJ+VKACd+66eYWyi4uTLLT3OUEVLLUNIAytbwPF+E=`.trim(); + +export const APPLE_ROOT_CA_G3 = + `MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS +b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y +aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf +TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 +IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA +MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 +at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM +6BgD56KyKA=`.trim(); diff --git a/apps/web/lib/payment-providers/app-store/layer.ts b/apps/web/lib/payment-providers/app-store/layer.ts new file mode 100644 index 000000000..e9759846c --- /dev/null +++ b/apps/web/lib/payment-providers/app-store/layer.ts @@ -0,0 +1,10 @@ +import { Layer, pipe } from 'effect'; +import { AppStoreTransactionRepository } from './repositories/app-store-transaction.repository'; +import { AppStoreService } from './services/app-store.service'; +import { AppStoreServerAPIService } from './services/app-store-server-api.service'; + +export const AppStoreProviderLayer = pipe( + AppStoreService.Default, + Layer.provideMerge(AppStoreServerAPIService.Default), + Layer.provideMerge(AppStoreTransactionRepository.Default) +); diff --git a/apps/web/lib/payment-providers/app-store/repositories/app-store-transaction.repository.ts b/apps/web/lib/payment-providers/app-store/repositories/app-store-transaction.repository.ts new file mode 100644 index 000000000..30d46d5f0 --- /dev/null +++ b/apps/web/lib/payment-providers/app-store/repositories/app-store-transaction.repository.ts @@ -0,0 +1,40 @@ +import { + appStoreTransactions, + eq, + type InsertAppStoreTransaction +} from '@voidhash/db'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; + +export class AppStoreTransactionRepository extends Effect.Service()( + 'AppStoreTransactionRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createAppStoreTransaction: dbService.makeQuery( + (execute, appStoreTransaction: InsertAppStoreTransaction) => + execute( + async (db) => + await db + .insert(appStoreTransactions) + .values(appStoreTransaction) + ) + ), + + getAppStoreTransactionByTransactionId: dbService.makeQuery( + (execute, transactionId: string) => + execute( + async (db) => + await db.query.appStoreTransactions.findFirst({ + where: eq(appStoreTransactions.transactionId, transactionId) + }) + ) + ) + }; + }), + + // Specify dependencies + dependencies: [Db.Default] + } +) {} diff --git a/apps/web/lib/payment-providers/app-store/services/app-store-server-api.service.ts b/apps/web/lib/payment-providers/app-store/services/app-store-server-api.service.ts new file mode 100644 index 000000000..d13d461a0 --- /dev/null +++ b/apps/web/lib/payment-providers/app-store/services/app-store-server-api.service.ts @@ -0,0 +1,244 @@ +import { + APIException, + Environment as AppStoreEnvironment, + AppStoreServerAPIClient, + SignedDataVerifier, + type TransactionInfoResponse, + VerificationException +} from '@apple/app-store-server-library'; +import { Data, Effect } from 'effect'; +import { + APPLE_ROOT_CA_G2, + APPLE_ROOT_CA_G3, + APPLE_ROOT_CERTIFICATE +} from '../constants'; + +export class AppStoreGeneralError extends Data.TaggedError( + 'AppStoreGeneralError' +)<{ + readonly cause?: unknown; + readonly message: string; +}> {} + +export class AppStoreTransactionNotFoundError extends Data.TaggedError( + 'AppStoreTransactionNotFoundError' +)<{ + readonly transactionId: string; +}> {} + +export class AppStoreUnauthorizedError extends Data.TaggedError( + 'AppStoreUnauthorizedError' +)<{ + readonly message: string; +}> {} + +export class AppStoreRateLimitExceededError extends Data.TaggedError( + 'AppStoreRateLimitExceededError' +)<{ + readonly message: string; +}> {} + +export class AppStoreSignedTransactionInfoNotFoundError extends Data.TaggedError( + 'AppStoreSignedTransactionInfoNotFoundError' +)<{ + readonly message: string; +}> {} + +export class AppStoreVerificationException extends Data.TaggedError( + 'AppStoreVerificationException' +)<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export type TransactionInfoResult = { + environment: 'production' | 'sandbox'; + transactionInfo: TransactionInfoResponse; +}; + +export class AppStoreServerAPIService extends Effect.Service()( + 'AppStoreServerAPIService', + { + dependencies: [], + effect: Effect.gen(function* () { + return { + initializeSdk: ({ + privateKey, + keyId, + issuerId, + bundleId + }: { + privateKey: string; + keyId: string; + issuerId: string; + bundleId: string; + }) => + Effect.gen(function* () { + const createClient = (environment: 'production' | 'sandbox') => + new AppStoreServerAPIClient( + privateKey, + keyId, + issuerId, + bundleId, + environment === 'production' + ? AppStoreEnvironment.PRODUCTION + : AppStoreEnvironment.SANDBOX + ); + + const getTransactionInfoFn = ( + transactionId: string, + environment: 'production' | 'sandbox' + ) => + Effect.tryPromise({ + try: async () => { + Effect.logDebug('Getting transaction info', { + transactionId, + environment + }); + const client = createClient(environment); + const transactionInf = + await client.getTransactionInfo(transactionId); + return { + environment, + transactionInfo: transactionInf + }; + }, + catch: (cause) => { + if (cause instanceof APIException) { + if (cause.httpStatusCode === 404) { + Effect.logDebug('Transaction not found', { + transactionId, + environment + }); + return new AppStoreTransactionNotFoundError({ + transactionId + }); + } + + if (cause.httpStatusCode === 401) { + Effect.logDebug('Unauthorized', { + transactionId, + environment + }); + return new AppStoreUnauthorizedError({ + message: 'Unauthorized' + }); + } + + if (cause.httpStatusCode === 429) { + Effect.logDebug('Rate limit exceeded', { + transactionId, + environment + }); + return new AppStoreRateLimitExceededError({ + message: 'Rate limit exceeded' + }); + } + + return new AppStoreGeneralError({ + message: + cause.errorMessage ?? + 'Failed to execute App Store Server API', + cause + }); + } + + return new AppStoreGeneralError({ + message: 'Failed to execute App Store Server API', + cause + }); + } + }); + + return { + /** + * Gets the transaction info from the App Store Server API + * @param transactionId - The transaction ID + * @returns The transaction info + */ + getTransactionInfo: (transactionId: string) => + getTransactionInfoFn(transactionId, 'production').pipe( + Effect.catchTag('AppStoreTransactionNotFoundError', () => + getTransactionInfoFn(transactionId, 'sandbox') + ) + ), + + /** + * Decodes the transaction using the App Store Server API + * @param transactionInfoResult - The transaction info result + * @returns The decoded transaction + */ + decodeTransaction: ( + transactionInfoResult: TransactionInfoResult + ) => + Effect.gen(function* () { + Effect.logDebug('Decoding transaction'); + const appleRootCertificate = Buffer.from( + APPLE_ROOT_CERTIFICATE, + 'base64' + ); + const appleRootCertificate2 = Buffer.from( + APPLE_ROOT_CA_G2, + 'base64' + ); + const appleRootCertificate3 = Buffer.from( + APPLE_ROOT_CA_G3, + 'base64' + ); + const certificates = [ + appleRootCertificate, + appleRootCertificate2, + appleRootCertificate3 + ]; + + const verifier = new SignedDataVerifier( + certificates, + true, + transactionInfoResult.environment === 'production' + ? AppStoreEnvironment.PRODUCTION + : AppStoreEnvironment.SANDBOX, + bundleId + ); + + const signedTransactionInfo = + transactionInfoResult.transactionInfo.signedTransactionInfo; + + if (!signedTransactionInfo) { + Effect.logDebug('Signed transaction info is not found'); + return yield* Effect.fail( + new AppStoreSignedTransactionInfoNotFoundError({ + message: 'Signed transaction info is not found' + }) + ); + } + + return yield* Effect.tryPromise({ + try: () => { + return verifier.verifyAndDecodeTransaction( + signedTransactionInfo + ); + }, + catch: (cause) => { + if (cause instanceof VerificationException) { + Effect.logDebug('Verification exception', { + cause + }); + return new AppStoreVerificationException({ + message: 'Failed to decode transaction', + cause + }); + } + + return new AppStoreGeneralError({ + message: 'Failed to decode transaction', + cause + }); + } + }); + }) + }; + }) + }; + }) + } +) {} diff --git a/apps/web/lib/payment-providers/app-store/services/app-store.service.ts b/apps/web/lib/payment-providers/app-store/services/app-store.service.ts index e69de29bb..d5dd9a9ca 100644 --- a/apps/web/lib/payment-providers/app-store/services/app-store.service.ts +++ b/apps/web/lib/payment-providers/app-store/services/app-store.service.ts @@ -0,0 +1,315 @@ +import type { JWSTransactionDecodedPayload } from '@apple/app-store-server-library'; +import type { PaymentProviderConfiguration } from '@voidhash/db'; +import { + type EnvironmentValue, + parseISO4217CurrencyCode +} from '@voidhash/lib/constants'; +import { Data, Effect } from 'effect'; +import type z from 'zod'; +import { PaymentProviderConfigurationRepository } from '@/lib/repositories/payment-provider.repository'; +import { PaymentProviderConfigurationProductRepository } from '@/lib/repositories/payment-provider-configuration-product.repository'; +import { AuthSession } from '@/lib/services/auth.service'; +import { Environment } from '@/lib/services/environment.service'; +import { appStore } from '../app-store'; +import { AppStoreServerAPIService } from './app-store-server-api.service'; + +export class AppStoreNotEnabledForThisBundleIdError extends Data.TaggedError( + 'AppStoreNotEnabledForThisBundleIdError' +)<{ + readonly message: string; +}> {} + +export class AppStoreServerAPIError extends Data.TaggedError( + 'AppStoreServerAPIError' +)<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export class AppStoreTransactionDoesNotContainProductIdError extends Data.TaggedError( + 'AppStoreTransactionDoesNotContainProductIdError' +)<{ + readonly message: string; +}> {} + +export class PaymentProviderConfigurationProductNotFound extends Data.TaggedError( + 'PaymentProviderConfigurationProductNotFound' +)<{ + readonly message: string; +}> {} + +export class AppStoreTransactionValidationFailed extends Data.TaggedError( + 'AppStoreTransactionDoesNotContainCustomerIdError' +)<{ + readonly message: string; +}> {} + +export class AppStoreService extends Effect.Service()( + 'AppStoreService', + { + dependencies: [], + effect: Effect.gen(function* () { + return { + validateTransaction: (input: { + transactionId: string; + bundleId: string; + environment: EnvironmentValue; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + const appStoreServerAPIService = yield* AppStoreServerAPIService; + // const db = yield* Db; + // const appStoreTransactionRepository = + // yield* AppStoreTransactionRepository; + const paymentProviderConfigurationRepository = + yield* PaymentProviderConfigurationRepository; + const paymentProviderConfigurationProductRepository = + yield* PaymentProviderConfigurationProductRepository; + + Effect.logDebug('Validating transaction', { + transactionId: input.transactionId, + bundleId: input.bundleId, + environment: input.environment + }); + + const projectId = session.projects[0]?.id; + if (!projectId) { + return yield* Effect.dieMessage( + 'Project ID does not exist in the session' + ); + } + + const paymentProviderConfigurations = + yield* paymentProviderConfigurationRepository.getPaymentProviderConfigurations( + projectId + ); + + // Load configuration + const appStorePaymentProviderConfiguration = + yield* getActiveAppStorePaymentProviderConfiguration( + paymentProviderConfigurations, + input.bundleId + ); + + const appStoreServerAPISdk = + yield* appStoreServerAPIService.initializeSdk( + appStorePaymentProviderConfiguration.configuration + ); + + // Get transaction info from App Store Server API + const transactionInfoResult = yield* appStoreServerAPISdk + .getTransactionInfo(input.transactionId) + .pipe( + Effect.catchTags({ + // TODO: Handle other errors - mostly to notify the user about incorrect configuration + }), + Effect.catchAll((error) => + Effect.gen(function* () { + return yield* Effect.fail( + new AppStoreServerAPIError({ + message: 'Failed to validate transaction', + cause: error + }) + ); + }) + ) + ); + + const decodedTransaction = yield* appStoreServerAPISdk + .decodeTransaction(transactionInfoResult) + .pipe(Effect.flatMap(ensureEncodedTransactionHasRequiredFields)); + + const paymentProviderConfigurationProduct = + yield* paymentProviderConfigurationProductRepository.getProviderProductByPrimaryKey( + { + paymentProviderConfigurationId: + appStorePaymentProviderConfiguration.id, + providerProductKey: decodedTransaction.productId, + environment + } + ); + + if (!paymentProviderConfigurationProduct) { + return yield* Effect.fail( + new PaymentProviderConfigurationProductNotFound({ + message: 'Payment provider configuration product not found. ' + }) + ); + } + + // const customerId = decodedTransaction.appAccountToken; + // const currency = decodedTransaction.currency; + // const transactionId = decodedTransaction.transactionId; + + return yield* Effect.succeed({ + success: true + }); + + // return yield* db.transaction((tx) => + // TransactionContext.provide(tx)( + // Effect.gen(function* () { + // const existingAppStoreTransaction = + // yield* appStoreTransactionRepository.getAppStoreTransactionByTransactionId( + // transactionId, + // ); + + // if (existingAppStoreTransaction) { + // // TODO: Update the transaction + // return; + // } + + // const newAppStoreTransaction = + // appStoreTransactionRepository.createAppStoreTransaction({ + // id: generateId("appStoreTransaction"), + + // }); + // }), + // ), + // ); + }) + }; + }) + } +) {} + +/** + * Finds the active App Store payment provider configuration by bundle ID + * @param paymentProviderConfigurations - The payment provider configurations to search through + * @param bundleId - The bundle ID to search for + * @returns The active App Store payment provider configuration if found, otherwise undefined + */ +const getActiveAppStorePaymentProviderConfiguration = ( + paymentProviderConfigurations: PaymentProviderConfiguration[], + bundleId: string +) => + Effect.gen(function* () { + const paymentProviderConfiguration = paymentProviderConfigurations.find( + (paymentProviderConfiguration) => + paymentProviderConfiguration.paymentProviderKey === + appStore.createGlobalKey({ + bundleId + }) && paymentProviderConfiguration.enabled + ); + + if (!paymentProviderConfiguration) { + return yield* Effect.fail( + new AppStoreNotEnabledForThisBundleIdError({ + message: 'App Store is not enabled for this bundle ID' + }) + ); + } + + return { + ...paymentProviderConfiguration, + configuration: paymentProviderConfiguration.configuration as z.infer< + ReturnType + > + }; + }); + +const ensureEncodedTransactionHasRequiredFields = ( + decodedTransaction: JWSTransactionDecodedPayload +) => + Effect.gen(function* () { + const productId = decodedTransaction.productId; + const appAccountToken = decodedTransaction.appAccountToken; + const currency = decodedTransaction.currency; + const transactionId = decodedTransaction.transactionId; + + if (!transactionId) { + return yield* Effect.fail( + new AppStoreTransactionValidationFailed({ + message: 'Transaction does not contain transaction ID' + }) + ); + } + + if (!productId) { + return yield* Effect.fail( + new AppStoreTransactionValidationFailed({ + message: 'Transaction does not contain product ID' + }) + ); + } + + if (!appAccountToken) { + return yield* Effect.fail( + new AppStoreTransactionValidationFailed({ + message: 'Transaction does not contain customer ID' + }) + ); + } + + if (!currency) { + return yield* Effect.fail( + new AppStoreTransactionValidationFailed({ + message: 'Transaction does not contain currency' + }) + ); + } + + const currencyStrict = yield* parseISO4217CurrencyCode(currency); + + return yield* Effect.succeed({ + ...decodedTransaction, + currency: currencyStrict, + customerId: appAccountToken, + productId + }); + }); + +// const mapDecodedTransactionToAppStoreTransactionInsert = (transaction: JWSTransactionDecodedPayload) => { +// return { +// transactionId: transaction.transactionId, +// currency: transaction.currency, +// environment: fromEnvironment( +// transaction.environment, +// ), +// expireDate: transaction.expiresDate +// ? new Date(transaction.expiresDate) +// : null, +// inAppOwnershipType: fromOwnershipType( +// transaction.inAppOwnershipType, +// ), +// isUpgraded: transaction.isUpgraded, +// offerDiscountType: transaction.offerDiscountType +// ? fromOfferDiscountType( +// transaction.offerDiscountType, +// ) +// : null, +// offerIdentifier: transaction.offerIdentifier, +// offerPeriod: transaction.offerPeriod, +// offerType: transaction.offerType +// ? fromOfferType(transaction.offerType) +// : null, +// originalPurchaseDate: new Date( +// transaction.originalPurchaseDate, +// ), +// originalTransactionId: +// transaction.originalTransactionId, +// price: transaction.price, +// productId: transaction.productId, +// purchaseDate: new Date(transaction.purchaseDate), +// quantity: transaction.quantity, +// revocationDate: transaction.revocationDate +// ? new Date(transaction.revocationDate) +// : null, +// revocationReason: transaction.revocationReason +// ? fromRevocationReason( +// transaction.revocationReason, +// ) +// : null, +// storefront: transaction.storefront, +// storefrontId: transaction.storefrontId, +// subscriptionGroupIdentifier: +// transaction.subscriptionGroupIdentifier, +// transactionReason: transaction.transactionReason +// ? fromTransactionReason( +// transaction.transactionReason, +// ) +// : null, +// type: fromTransactionType(transaction.type), +// webOrderLineItemId: transaction.webOrderLineItemId, +// } +// } diff --git a/apps/web/lib/payment-providers/app-store/utils.ts b/apps/web/lib/payment-providers/app-store/utils.ts index 025336f3c..fa2c35773 100644 --- a/apps/web/lib/payment-providers/app-store/utils.ts +++ b/apps/web/lib/payment-providers/app-store/utils.ts @@ -1,73 +1,83 @@ import { - Environment, - OfferDiscountType, - OfferType, - OwnershipType, - TransactionReason, - TransactionType, -} from "app-store-server-api"; + Environment, + InAppOwnershipType, + OfferDiscountType, + OfferType, + TransactionReason, + Type +} from '@apple/app-store-server-library'; export const fromEnvironment = (environment: Environment) => { - return environment === Environment.Production ? "production" : "sandbox"; + return environment === Environment.PRODUCTION ? 'production' : 'sandbox'; }; -export const fromOwnershipType = (ownershipType: OwnershipType) => { - return ownershipType === OwnershipType.FamilyShared - ? "FAMILY_SHARED" - : "PURCHASED"; +export const fromOwnershipType = (ownershipType: InAppOwnershipType) => { + return ownershipType === InAppOwnershipType.FAMILY_SHARED + ? 'FAMILY_SHARED' + : 'PURCHASED'; }; export const fromOfferDiscountType = (offerDiscountType: OfferDiscountType) => { - switch (offerDiscountType) { - case OfferDiscountType.FreeTrial: - return "FREE_TRIAL"; - case OfferDiscountType.PayAsYouGo: - return "PAY_AS_YOU_GO"; - case OfferDiscountType.PayUpFront: - return "PAY_UP_FRONT"; - } + switch (offerDiscountType) { + case OfferDiscountType.FREE_TRIAL: + return 'FREE_TRIAL'; + case OfferDiscountType.PAY_AS_YOU_GO: + return 'PAY_AS_YOU_GO'; + case OfferDiscountType.PAY_UP_FRONT: + return 'PAY_UP_FRONT'; + default: + throw new Error('Unknown offer discount type'); + } }; export const fromOfferType = (offerType: OfferType) => { - switch (offerType) { - case OfferType.Introductory: - return "INTRODUCTORY_OFFER"; - case OfferType.Promotional: - return "PROMOTIONAL_OFFER"; - case OfferType.SubscriptionOfferCode: - return "OFFER_WITH_SUBSCRIPTION_OFFER_CODE"; - case OfferType.WinBackOffer: - return "WIN_BACK_OFFER"; - } + switch (offerType) { + case OfferType.INTRODUCTORY_OFFER: + return 'INTRODUCTORY_OFFER'; + case OfferType.PROMOTIONAL_OFFER: + return 'PROMOTIONAL_OFFER'; + case OfferType.SUBSCRIPTION_OFFER_CODE: + return 'SUBSCRIPTION_OFFER_CODE'; + case OfferType.WIN_BACK_OFFER: + return 'WIN_BACK_OFFER'; + default: + throw new Error('Unknown offer type'); + } }; export const fromRevocationReason = (revocationReason: number) => { - switch (revocationReason) { - case 1: - return "OTHER_REASON"; - case 2: - return "PERCEIVED_ISSUE"; - } + switch (revocationReason) { + case 1: + return 'OTHER_REASON'; + case 2: + return 'PERCEIVED_ISSUE'; + default: + throw new Error('Unknown revocation reason'); + } }; export const fromTransactionReason = (transactionReason: TransactionReason) => { - switch (transactionReason) { - case TransactionReason.Purchase: - return "PURCHASE"; - case TransactionReason.Renewal: - return "RENEWAL"; - } + switch (transactionReason) { + case TransactionReason.PURCHASE: + return 'PURCHASE'; + case TransactionReason.RENEWAL: + return 'RENEWAL'; + default: + throw new Error('Unknown transaction reason'); + } }; -export const fromTransactionType = (transactionType: TransactionType) => { - switch (transactionType) { - case TransactionType.AutoRenewableSubscription: - return "AUTO_RENEWABLE_SUBSCRIPTION"; - case TransactionType.NonConsumable: - return "NON_CONSUMABLE"; - case TransactionType.Consumable: - return "CONSUMABLE"; - case TransactionType.NonRenewingSubscription: - return "NON_RENEWING_SUBSCRIPTION"; - } +export const fromTransactionType = (transactionType: Type) => { + switch (transactionType) { + case Type.AUTO_RENEWABLE_SUBSCRIPTION: + return 'AUTO_RENEWABLE_SUBSCRIPTION'; + case Type.NON_CONSUMABLE: + return 'NON_CONSUMABLE'; + case Type.CONSUMABLE: + return 'CONSUMABLE'; + case Type.NON_RENEWING_SUBSCRIPTION: + return 'NON_RENEWING_SUBSCRIPTION'; + default: + throw new Error('Unknown transaction type'); + } }; diff --git a/apps/web/lib/payment-providers/dev-checkout/actions/cancel-purchase.ts b/apps/web/lib/payment-providers/dev-checkout/actions/cancel-purchase.ts index ba87eacee..c8daddeea 100644 --- a/apps/web/lib/payment-providers/dev-checkout/actions/cancel-purchase.ts +++ b/apps/web/lib/payment-providers/dev-checkout/actions/cancel-purchase.ts @@ -1,74 +1,74 @@ -import { CheckoutSessionRepository } from "@/lib/repositories/checkout-session.repository"; -import { Data, Effect, pipe, Schema } from "effect"; -import { CheckoutSessionStatus } from "@voidhash/db"; +import { CheckoutSessionStatus } from '@voidhash/db'; +import { Data, Effect, pipe, Schema } from 'effect'; +import { CheckoutSessionRepository } from '@/lib/repositories/checkout-session.repository'; export const cancelDevCheckoutPurchaseInputSchema = Schema.Struct({ - checkoutSessionId: Schema.String, + checkoutSessionId: Schema.String }); type CancelDevCheckoutPurchaseInput = Schema.Schema.Type< - typeof cancelDevCheckoutPurchaseInputSchema + typeof cancelDevCheckoutPurchaseInputSchema >; export class CheckoutSessionNotFound extends Data.TaggedError( - "CheckoutSessionNotFound" + 'CheckoutSessionNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class CheckoutSessionWasAlreadyCancelled extends Data.TaggedError( - "CheckoutSessionWasAlreadyCancelled" + 'CheckoutSessionWasAlreadyCancelled' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class CheckoutSessionWasAlreadyConfirmed extends Data.TaggedError( - "CheckoutSessionWasAlreadyConfirmed" + 'CheckoutSessionWasAlreadyConfirmed' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export const cancelPurchase = (inputUnsafe: CancelDevCheckoutPurchaseInput) => - pipe( - Effect.gen(function* () { - const checkoutSessionRepository = yield* CheckoutSessionRepository; - const checkoutSession = - yield* checkoutSessionRepository.getCheckoutSessionById( - inputUnsafe.checkoutSessionId - ); + pipe( + Effect.gen(function* () { + const checkoutSessionRepository = yield* CheckoutSessionRepository; + const checkoutSession = + yield* checkoutSessionRepository.getCheckoutSessionById( + inputUnsafe.checkoutSessionId + ); - if (!checkoutSession) { - return yield* Effect.fail( - new CheckoutSessionNotFound({ - message: "Checkout session not found", - }) - ); - } + if (!checkoutSession) { + return yield* Effect.fail( + new CheckoutSessionNotFound({ + message: 'Checkout session not found' + }) + ); + } - if (checkoutSession.status === CheckoutSessionStatus.Cancelled) { - return { - redirectUrl: checkoutSession.successCallbackUrl, - }; - } + if (checkoutSession.status === CheckoutSessionStatus.Cancelled) { + return { + redirectUrl: checkoutSession.successCallbackUrl + }; + } - if (checkoutSession.status === CheckoutSessionStatus.Success) { - return yield* Effect.fail( - new CheckoutSessionWasAlreadyConfirmed({ - message: "Checkout session was already confirmed", - }) - ); - } + if (checkoutSession.status === CheckoutSessionStatus.Success) { + return yield* Effect.fail( + new CheckoutSessionWasAlreadyConfirmed({ + message: 'Checkout session was already confirmed' + }) + ); + } - yield* checkoutSessionRepository.updateCheckoutSession({ - id: inputUnsafe.checkoutSessionId, - status: CheckoutSessionStatus.Cancelled, - }); + yield* checkoutSessionRepository.updateCheckoutSession({ + id: inputUnsafe.checkoutSessionId, + status: CheckoutSessionStatus.Cancelled + }); - return { - redirectUrl: checkoutSession.successCallbackUrl, - }; - }) - ); + return { + redirectUrl: checkoutSession.successCallbackUrl + }; + }) + ); diff --git a/apps/web/lib/payment-providers/dev-checkout/actions/confirm-purchase.ts b/apps/web/lib/payment-providers/dev-checkout/actions/confirm-purchase.ts index 086143dbe..82d20d4cc 100644 --- a/apps/web/lib/payment-providers/dev-checkout/actions/confirm-purchase.ts +++ b/apps/web/lib/payment-providers/dev-checkout/actions/confirm-purchase.ts @@ -1,95 +1,97 @@ -import { CheckoutSessionRepository } from "@/lib/repositories/checkout-session.repository"; -import { CheckoutSession, CheckoutSessionStatus } from "@voidhash/db"; -import { Data, Effect, pipe, Schema } from "effect"; -import { PaymentProviderConfigurationProductRepository } from "@/lib/repositories/payment-provider-configuration-product.repository"; +import { type CheckoutSession, CheckoutSessionStatus } from '@voidhash/db'; +import { Data, Effect, pipe, Schema } from 'effect'; +import { CheckoutSessionRepository } from '@/lib/repositories/checkout-session.repository'; +import { PaymentProviderConfigurationProductRepository } from '@/lib/repositories/payment-provider-configuration-product.repository'; export const confirmDevCheckoutPurchaseInputSchema = Schema.Struct({ - checkoutSessionId: Schema.String, + checkoutSessionId: Schema.String }); type ConfirmDevCheckoutPurchaseInput = Schema.Schema.Type< - typeof confirmDevCheckoutPurchaseInputSchema + typeof confirmDevCheckoutPurchaseInputSchema >; export class CheckoutSessionNotFound extends Data.TaggedError( - "CheckoutSessionNotFound" + 'CheckoutSessionNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class CheckoutSessionWasAlreadyCancelled extends Data.TaggedError( - "CheckoutSessionWasAlreadyCancelled" + 'CheckoutSessionWasAlreadyCancelled' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export const confirmPurchase = (inputUnsafe: ConfirmDevCheckoutPurchaseInput) => - pipe( - Effect.gen(function* () { - const checkoutSessionRepository = yield* CheckoutSessionRepository; - const paymentProviderConfigurationProductRepository = yield* PaymentProviderConfigurationProductRepository; + pipe( + Effect.gen(function* () { + const checkoutSessionRepository = yield* CheckoutSessionRepository; + const paymentProviderConfigurationProductRepository = + yield* PaymentProviderConfigurationProductRepository; - // Load the checkout session - const checkoutSession = - yield* checkoutSessionRepository.getCheckoutSessionById( - inputUnsafe.checkoutSessionId - ); - if (!checkoutSession) { - return yield* Effect.fail( - new CheckoutSessionNotFound({ - message: "Checkout session not found", - }) - ); - } - if (checkoutSession.status !== CheckoutSessionStatus.Pending) { - return yield* handleAlreadyProcessedCheckoutSession(checkoutSession); - } + // Load the checkout session + const checkoutSession = + yield* checkoutSessionRepository.getCheckoutSessionById( + inputUnsafe.checkoutSessionId + ); + if (!checkoutSession) { + return yield* Effect.fail( + new CheckoutSessionNotFound({ + message: 'Checkout session not found' + }) + ); + } + if (checkoutSession.status !== CheckoutSessionStatus.Pending) { + return yield* handleAlreadyProcessedCheckoutSession(checkoutSession); + } - // Process the purchase - // TODO: Process the purchase - const paymentProviderConfigurationProduct = - yield* paymentProviderConfigurationProductRepository.getProviderProductById( - checkoutSession.paymentProviderConfigurationProductId - ); - if (!paymentProviderConfigurationProduct) - return yield* Effect.dieMessage( - "Payment provider configuration product saved in checkout session is not found. This is an inconsistency in the checkout session and this should never happen." - ); + // Process the purchase + // TODO: Process the purchase + const paymentProviderConfigurationProduct = + yield* paymentProviderConfigurationProductRepository.getProviderProductById( + checkoutSession.paymentProviderConfigurationProductId + ); + if (!paymentProviderConfigurationProduct) { + return yield* Effect.dieMessage( + 'Payment provider configuration product saved in checkout session is not found. This is an inconsistency in the checkout session and this should never happen.' + ); + } - yield* checkoutSessionRepository.updateCheckoutSession({ - id: inputUnsafe.checkoutSessionId, - status: CheckoutSessionStatus.Success, - }); + yield* checkoutSessionRepository.updateCheckoutSession({ + id: inputUnsafe.checkoutSessionId, + status: CheckoutSessionStatus.Success + }); - return { - redirectUrl: checkoutSession.successCallbackUrl, - }; - }) - ); + return { + redirectUrl: checkoutSession.successCallbackUrl + }; + }) + ); const handleAlreadyProcessedCheckoutSession = ( - checkoutSession: CheckoutSession + checkoutSession: CheckoutSession ) => - Effect.gen(function* () { - if (checkoutSession.status === CheckoutSessionStatus.Success) { - return { - redirectUrl: checkoutSession.successCallbackUrl, - }; - } + Effect.gen(function* () { + if (checkoutSession.status === CheckoutSessionStatus.Success) { + return { + redirectUrl: checkoutSession.successCallbackUrl + }; + } - if (checkoutSession.status === CheckoutSessionStatus.Cancelled) { - return yield* Effect.fail( - new CheckoutSessionWasAlreadyCancelled({ - message: "Checkout session was already cancelled", - }) - ); - } + if (checkoutSession.status === CheckoutSessionStatus.Cancelled) { + return yield* Effect.fail( + new CheckoutSessionWasAlreadyCancelled({ + message: 'Checkout session was already cancelled' + }) + ); + } - if (checkoutSession.status === CheckoutSessionStatus.Error) { - return { - redirectUrl: checkoutSession.errorCallbackUrl, - }; - } - }); + if (checkoutSession.status === CheckoutSessionStatus.Error) { + return { + redirectUrl: checkoutSession.errorCallbackUrl + }; + } + }); diff --git a/apps/web/lib/payment-providers/dev-checkout/dev-checkout.service.ts b/apps/web/lib/payment-providers/dev-checkout/dev-checkout.service.ts index 77eab25c0..ba1ad53b6 100644 --- a/apps/web/lib/payment-providers/dev-checkout/dev-checkout.service.ts +++ b/apps/web/lib/payment-providers/dev-checkout/dev-checkout.service.ts @@ -1,15 +1,16 @@ -import { Effect } from "effect"; -import { confirmPurchase } from "./actions/confirm-purchase"; -import { cancelPurchase } from "./actions/cancel-purchase"; +import { Effect } from 'effect'; +import { cancelPurchase } from './actions/cancel-purchase'; +import { confirmPurchase } from './actions/confirm-purchase'; -export class DevCheckoutService extends Effect.Service()("DevCheckoutService", { - effect: Effect.gen(function* () { - return { - confirmPurchase, - cancelPurchase, - }; - }), +export class DevCheckoutService extends Effect.Service()( + 'DevCheckoutService', + { + effect: Effect.succeed({ + confirmPurchase, + cancelPurchase + }), - // Specify dependencies - dependencies: [], -}) {} + // Specify dependencies + dependencies: [] + } +) {} diff --git a/apps/web/lib/payment-providers/dev-checkout/dev-checkout.ts b/apps/web/lib/payment-providers/dev-checkout/dev-checkout.ts index fdc9d7a13..56ef1bda7 100644 --- a/apps/web/lib/payment-providers/dev-checkout/dev-checkout.ts +++ b/apps/web/lib/payment-providers/dev-checkout/dev-checkout.ts @@ -1,100 +1,100 @@ -import { z } from "zod"; -import { BasePaymentProvider } from "../../core/payment-providers/base-payment-provider"; -import { PaymentProvider } from "../../core/payment-providers/payment-provider"; -import { - PaymentProviderConfigurationSheetSection, - PaymentProviderProductEditorSheetSection, -} from "../../core/payment-providers/types"; -import { Environment } from "@voidhash/lib/index"; +import { Environment } from '@voidhash/lib/index'; +import { z } from 'zod'; +import { BasePaymentProvider } from '../../core/payment-providers/base-payment-provider'; +import type { PaymentProvider } from '../../core/payment-providers/payment-provider'; +import type { + PaymentProviderConfigurationSheetSection, + PaymentProviderProductEditorSheetSection +} from '../../core/payment-providers/types'; -export const devCheckoutPaymentProviderId = "dev-checkout" as const; +export const devCheckoutPaymentProviderId = 'dev-checkout' as const; const devCheckoutGlobalConfigurationSchema = z.object({ - paymentProviderConfigurationId: z.string().min(1), + paymentProviderConfigurationId: z.string().min(1) }); const devCheckoutProductConfigurationSchema = z.object({ - productId: z.string().min(1), + productId: z.string().min(1) }); export class DevCheckoutPaymentProvider - extends BasePaymentProvider< - typeof devCheckoutPaymentProviderId, - typeof devCheckoutGlobalConfigurationSchema, - typeof devCheckoutProductConfigurationSchema - > - implements - PaymentProvider< - typeof devCheckoutPaymentProviderId, - typeof devCheckoutGlobalConfigurationSchema, - typeof devCheckoutProductConfigurationSchema - > + extends BasePaymentProvider< + typeof devCheckoutPaymentProviderId, + typeof devCheckoutGlobalConfigurationSchema, + typeof devCheckoutProductConfigurationSchema + > + implements + PaymentProvider< + typeof devCheckoutPaymentProviderId, + typeof devCheckoutGlobalConfigurationSchema, + typeof devCheckoutProductConfigurationSchema + > { - constructor() { - super( - devCheckoutPaymentProviderId, - "Dev Checkout", - [Environment.Testing], - ["paymentProviderConfigurationId"], - ["productId"], - "web-checkout" - ); - } - getIsConfigurable(): boolean { - return false; - } - getDefaultGlobalConfiguration(): Partial< - z.infer - > { - return { - paymentProviderConfigurationId: "", - }; - } - getGlobalConfigurationSchema(): typeof devCheckoutGlobalConfigurationSchema { - return devCheckoutGlobalConfigurationSchema; - } - getGlobalConfigurationSheet(): { - sections: PaymentProviderConfigurationSheetSection[]; - } { - return { - sections: [], - }; - } - getIsProductConfigurable(): boolean { - return false; - } - getDefaultProductConfiguration(): Partial< - z.infer - > { - return { - productId: "", - }; - } - getProductConfigurationSchema(): typeof devCheckoutProductConfigurationSchema { - return devCheckoutProductConfigurationSchema; - } - getProductConfigurationSheet(): { - sections: PaymentProviderProductEditorSheetSection[]; - } { - return { - sections: [ - { - key: "productId", - type: "text-input", - name: "productId", - label: "Product ID", - input: { - type: "text", - placeholder: "prod_...", - }, - }, - ], - }; - } + constructor() { + super( + devCheckoutPaymentProviderId, + 'Dev Checkout', + [Environment.Testing], + ['paymentProviderConfigurationId'], + ['productId'], + 'web-checkout' + ); + } + getIsConfigurable(): boolean { + return false; + } + getDefaultGlobalConfiguration(): Partial< + z.infer + > { + return { + paymentProviderConfigurationId: '' + }; + } + getGlobalConfigurationSchema(): typeof devCheckoutGlobalConfigurationSchema { + return devCheckoutGlobalConfigurationSchema; + } + getGlobalConfigurationSheet(): { + sections: PaymentProviderConfigurationSheetSection[]; + } { + return { + sections: [] + }; + } + getIsProductConfigurable(): boolean { + return false; + } + getDefaultProductConfiguration(): Partial< + z.infer + > { + return { + productId: '' + }; + } + getProductConfigurationSchema(): typeof devCheckoutProductConfigurationSchema { + return devCheckoutProductConfigurationSchema; + } + getProductConfigurationSheet(): { + sections: PaymentProviderProductEditorSheetSection[]; + } { + return { + sections: [ + { + key: 'productId', + type: 'text-input', + name: 'productId', + label: 'Product ID', + input: { + type: 'text', + placeholder: 'prod_...' + } + } + ] + }; + } - checkIfCorrectlyConfigured(): boolean { - return true; - } + checkIfCorrectlyConfigured(): boolean { + return true; + } } export const devCheckout = new DevCheckoutPaymentProvider(); diff --git a/apps/web/lib/payment-providers/payment-providers-api.ts b/apps/web/lib/payment-providers/payment-providers-api.ts index 665d9a99c..e7d3eb572 100644 --- a/apps/web/lib/payment-providers/payment-providers-api.ts +++ b/apps/web/lib/payment-providers/payment-providers-api.ts @@ -1,3 +1,4 @@ -import { stripeApi } from "./stripe/stripe-api"; +import { appStoreApi } from './app-store/app-store-api'; +import { stripeApi } from './stripe/stripe-api'; -export const paymentProviderApis = [stripeApi] as const; +export const paymentProviderApis = [stripeApi, appStoreApi] as const; diff --git a/apps/web/lib/payment-providers/payment-providers.ts b/apps/web/lib/payment-providers/payment-providers.ts index af530e031..e5697b9fd 100644 --- a/apps/web/lib/payment-providers/payment-providers.ts +++ b/apps/web/lib/payment-providers/payment-providers.ts @@ -1,5 +1,5 @@ -import { stripe } from "./stripe/stripe"; -import { devCheckout } from "./dev-checkout/dev-checkout"; -import { appStore } from "./app-store/app-store"; +import { appStore } from './app-store/app-store'; +import { devCheckout } from './dev-checkout/dev-checkout'; +import { stripe } from './stripe/stripe'; export const paymentProviders = [stripe, devCheckout, appStore] as const; diff --git a/apps/web/lib/payment-providers/stripe/api/schema.ts b/apps/web/lib/payment-providers/stripe/api/schema.ts index e02da61e7..3f3240f7d 100644 --- a/apps/web/lib/payment-providers/stripe/api/schema.ts +++ b/apps/web/lib/payment-providers/stripe/api/schema.ts @@ -1,6 +1,6 @@ -import { z } from "zod"; +import { z } from 'zod'; export const createCheckoutBodySchema = z.object({ - productId: z.string(), - appUserId: z.string(), + productId: z.string(), + appUserId: z.string() }); diff --git a/apps/web/lib/payment-providers/stripe/constants.ts b/apps/web/lib/payment-providers/stripe/constants.ts index e71ebd6df..3f06d1ba4 100644 --- a/apps/web/lib/payment-providers/stripe/constants.ts +++ b/apps/web/lib/payment-providers/stripe/constants.ts @@ -1,22 +1,22 @@ -import type Stripe from "stripe"; +import type Stripe from 'stripe'; export const ALLOWED_EVENTS: Stripe.Event.Type[] = [ - "checkout.session.completed", - "customer.subscription.created", - "customer.subscription.updated", - "customer.subscription.deleted", - "customer.subscription.paused", - "customer.subscription.resumed", - // "customer.subscription.pending_update_applied", - // "customer.subscription.pending_update_expired", - // "customer.subscription.trial_will_end", - // "invoice.paid", - // "invoice.payment_failed", - // "invoice.payment_action_required", - // "invoice.upcoming", - // "invoice.marked_uncollectible", - // "invoice.payment_succeeded", - // "payment_intent.succeeded", - // "payment_intent.payment_failed", - // "payment_intent.canceled", + 'checkout.session.completed', + 'customer.subscription.created', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'customer.subscription.paused', + 'customer.subscription.resumed' + // "customer.subscription.pending_update_applied", + // "customer.subscription.pending_update_expired", + // "customer.subscription.trial_will_end", + // "invoice.paid", + // "invoice.payment_failed", + // "invoice.payment_action_required", + // "invoice.upcoming", + // "invoice.marked_uncollectible", + // "invoice.payment_succeeded", + // "payment_intent.succeeded", + // "payment_intent.payment_failed", + // "payment_intent.canceled", ]; diff --git a/apps/web/lib/payment-providers/stripe/stripe-api.ts b/apps/web/lib/payment-providers/stripe/stripe-api.ts index 2a8606ce1..76dd06f53 100644 --- a/apps/web/lib/payment-providers/stripe/stripe-api.ts +++ b/apps/web/lib/payment-providers/stripe/stripe-api.ts @@ -1,10 +1,10 @@ -import { createPaymentProviderApi } from "@/lib/core/payment-providers/payment-provider-api"; // import { registerStripeWebhook } from "./api/webhook"; -import { App } from "@/lib/api/hono/app"; +import type { App } from '@/lib/api/hono/app'; +import { createPaymentProviderApi } from '@/lib/core/payment-providers/payment-provider-api'; export const stripeApi = createPaymentProviderApi({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - registerEndpoints: (app: App) => { - // registerStripeWebhook(app); - }, + // biome-ignore lint/correctness/noUnusedFunctionParameters: TODO + registerEndpoints: (app: App) => { + // registerStripeWebhook(app); + } }); diff --git a/apps/web/lib/payment-providers/stripe/stripe.ts b/apps/web/lib/payment-providers/stripe/stripe.ts index d0eea4a50..9d89b0452 100644 --- a/apps/web/lib/payment-providers/stripe/stripe.ts +++ b/apps/web/lib/payment-providers/stripe/stripe.ts @@ -1,155 +1,156 @@ -import { z } from "zod"; -import { BasePaymentProvider } from "../../core/payment-providers/base-payment-provider"; -import { PaymentProvider } from "../../core/payment-providers/payment-provider"; -import { - PaymentProviderConfigurationSheetSection, - PaymentProviderProductEditorSheetSection, -} from "../../core/payment-providers/types"; -import { API_DOMAIN, Environment } from "@voidhash/lib/constants"; +import { API_DOMAIN, Environment } from '@voidhash/lib/constants'; +import { z } from 'zod'; +import { BasePaymentProvider } from '../../core/payment-providers/base-payment-provider'; +import type { PaymentProvider } from '../../core/payment-providers/payment-provider'; +import type { + PaymentProviderConfigurationSheetSection, + PaymentProviderProductEditorSheetSection +} from '../../core/payment-providers/types'; const stripeGlobalConfigurationSchema = z.object({ - secretKey: z.string().min(1), - webhookSecret: z.string().min(1), + secretKey: z.string().min(1), + webhookSecret: z.string().min(1) }); const stripeProductConfigurationSchema = z.object({ - productId: z - .string() - .min(1, { - message: "Product ID is required", - }) - .refine((id) => id.startsWith("prod_") || id.startsWith("prod_test_"), { - message: "Product ID must start with 'prod_' or 'prod_test_'", - }), - priceId: z - .string() - .min(1, { - message: "Price ID is required", - }) - .refine((id) => id.startsWith("price_") || id.startsWith("price_test_"), { - message: "Price ID must start with 'price_' or 'price_test_'", - }), + productId: z + .string() + .min(1, { + message: 'Product ID is required' + }) + .refine((id) => id.startsWith('prod_') || id.startsWith('prod_test_'), { + message: "Product ID must start with 'prod_' or 'prod_test_'" + }), + priceId: z + .string() + .min(1, { + message: 'Price ID is required' + }) + .refine((id) => id.startsWith('price_') || id.startsWith('price_test_'), { + message: "Price ID must start with 'price_' or 'price_test_'" + }) }); -export const stripePaymentProviderId = "stripe" as const; +export const stripePaymentProviderId = 'stripe' as const; export class StripePaymentProvider - extends BasePaymentProvider< - typeof stripePaymentProviderId, - typeof stripeGlobalConfigurationSchema, - typeof stripeProductConfigurationSchema - > - implements - PaymentProvider< - typeof stripePaymentProviderId, - typeof stripeGlobalConfigurationSchema, - typeof stripeProductConfigurationSchema - > + extends BasePaymentProvider< + typeof stripePaymentProviderId, + typeof stripeGlobalConfigurationSchema, + typeof stripeProductConfigurationSchema + > + implements + PaymentProvider< + typeof stripePaymentProviderId, + typeof stripeGlobalConfigurationSchema, + typeof stripeProductConfigurationSchema + > { - constructor() { - super( - stripePaymentProviderId, - "Stripe", - [Environment.Production], - ["secretKey"], - ["productId", "priceId"], - "web-checkout" - ); - } - getIsConfigurable(): boolean { - return true; - } - getDefaultGlobalConfiguration() { - return { - secretKey: "", - webhookSecret: "", - }; - } - getGlobalConfigurationSchema() { - return stripeGlobalConfigurationSchema; - } - getGlobalConfigurationSheet({ projectId }): { - sections: PaymentProviderConfigurationSheetSection[]; - } { - return { - sections: [ - { - key: "secretKey", - type: "text-input", - name: "secretKey", - label: "Secret Key", - input: { - type: "text", - placeholder: "sk_...", - }, - }, - { - key: "webhookSecret", - type: "text-input", - name: "webhookSecret", - label: "Webhook Secret", - input: { - type: "text", - placeholder: "whsec_...", - }, - }, - { - key: "webhookUrl", - type: "copy-text", - label: "Webhook URL", - text: `${API_DOMAIN}/payment-providers/stripe/webhook/${projectId}`, - }, - ], - }; - } - getIsProductConfigurable(): boolean { - return true; - } - getDefaultProductConfiguration() { - return { - productId: "", - priceId: "", - }; - } - getProductConfigurationSchema() { - return stripeProductConfigurationSchema; - } - getProductConfigurationSheet() { - const sections: PaymentProviderProductEditorSheetSection[] = [ - { - key: "productId", - type: "text-input", - name: "productId", - label: "Product ID", - input: { - type: "text", - placeholder: "prod_...", - }, - }, - { - key: "priceId", - type: "text-input", - name: "priceId", - label: "Price ID", - input: { - type: "text", - placeholder: "price_...", - }, - }, - ]; + constructor() { + super( + stripePaymentProviderId, + 'Stripe', + [Environment.Production], + ['secretKey'], + ['productId', 'priceId'], + 'web-checkout' + ); + } + getIsConfigurable(): boolean { + return true; + } + getDefaultGlobalConfiguration() { + return { + secretKey: '', + webhookSecret: '' + }; + } + getGlobalConfigurationSchema() { + return stripeGlobalConfigurationSchema; + } + getGlobalConfigurationSheet({ projectId }): { + sections: PaymentProviderConfigurationSheetSection[]; + } { + return { + sections: [ + { + key: 'secretKey', + type: 'text-input', + name: 'secretKey', + label: 'Secret Key', + input: { + type: 'text', + placeholder: 'sk_...' + } + }, + { + key: 'webhookSecret', + type: 'text-input', + name: 'webhookSecret', + label: 'Webhook Secret', + input: { + type: 'text', + placeholder: 'whsec_...' + } + }, + { + key: 'webhookUrl', + type: 'copy-text', + label: 'Webhook URL', + text: `${API_DOMAIN}/payment-providers/stripe/webhook/${projectId}` + } + ] + }; + } + getIsProductConfigurable(): boolean { + return true; + } + getDefaultProductConfiguration() { + return { + productId: '', + priceId: '' + }; + } + getProductConfigurationSchema() { + return stripeProductConfigurationSchema; + } + getProductConfigurationSheet() { + const sections: PaymentProviderProductEditorSheetSection[] = [ + { + key: 'productId', + type: 'text-input', + name: 'productId', + label: 'Product ID', + input: { + type: 'text', + placeholder: 'prod_...' + } + }, + { + key: 'priceId', + type: 'text-input', + name: 'priceId', + label: 'Price ID', + input: { + type: 'text', + placeholder: 'price_...' + } + } + ]; - return { - sections, - }; - } + return { + sections + }; + } - checkIfCorrectlyConfigured( - configuration: z.infer - ): boolean { - // TODO: Implement - console.log(configuration); - return true; - } + checkIfCorrectlyConfigured( + configuration: z.infer + ): boolean { + // TODO: Implement + // biome-ignore lint/suspicious/noConsole: TODO + console.log(configuration); + return true; + } } export const stripe = new StripePaymentProvider(); diff --git a/apps/web/lib/payment-providers/stripe/utils.ts b/apps/web/lib/payment-providers/stripe/utils.ts index 281602402..539ba0b3c 100644 --- a/apps/web/lib/payment-providers/stripe/utils.ts +++ b/apps/web/lib/payment-providers/stripe/utils.ts @@ -1,20 +1,20 @@ import { - SubscriptionStatus, - SubscriptionStatusValue, -} from "@voidhash/lib/constants"; -import { Stripe } from "stripe"; + SubscriptionStatus, + type SubscriptionStatusValue +} from '@voidhash/lib/constants'; +import type { Stripe } from 'stripe'; export const mapSubscriptionStatus = ( - status: Stripe.Subscription.Status + status: Stripe.Subscription.Status ): SubscriptionStatusValue => { - switch (status) { - case "active": - return SubscriptionStatus.Active; + switch (status) { + case 'active': + return SubscriptionStatus.Active; - case "trialing": - return SubscriptionStatus.Active; + case 'trialing': + return SubscriptionStatus.Active; - default: - return SubscriptionStatus.Canceled; - } + default: + return SubscriptionStatus.Canceled; + } }; diff --git a/apps/web/lib/repositories/api-key.repository.ts b/apps/web/lib/repositories/api-key.repository.ts index d8afc5e2d..39b50eaae 100644 --- a/apps/web/lib/repositories/api-key.repository.ts +++ b/apps/web/lib/repositories/api-key.repository.ts @@ -1,60 +1,67 @@ -import { Db } from "@/lib/effect/db"; -import { ApiKey, apiKeys, asc, desc, eq, InsertApiKey } from "@voidhash/db"; -import { Effect } from "effect"; +import { + type ApiKey, + apiKeys, + asc, + desc, + eq, + type InsertApiKey +} from '@voidhash/db'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class ApiKeyRepository extends Effect.Service()( - "ApiKeyRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createApiKey: dbService.makeQuery((execute, apiKey: InsertApiKey) => - execute(async (db) => { - await db.insert(apiKeys).values(apiKey); - return { id: apiKey.id }; - }) - ), + 'ApiKeyRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createApiKey: dbService.makeQuery((execute, apiKey: InsertApiKey) => + execute(async (db) => { + await db.insert(apiKeys).values(apiKey); + return { id: apiKey.id }; + }) + ), - getApiKeyById: dbService.makeQuery((execute, id: string) => - execute( - async (db) => - await db.query.apiKeys.findFirst({ - where: eq(apiKeys.id, id), - }) - ) - ), + getApiKeyById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.apiKeys.findFirst({ + where: eq(apiKeys.id, id) + }) + ) + ), - getApiKeys: dbService.makeQuery((execute, projectId: string) => - execute( - async (db) => - await db.query.apiKeys.findMany({ - where: eq(apiKeys.projectId, projectId), - orderBy: [desc(apiKeys.isPublic), asc(apiKeys.createdAt)], - }) - ) - ), + getApiKeys: dbService.makeQuery((execute, projectId: string) => + execute( + async (db) => + await db.query.apiKeys.findMany({ + where: eq(apiKeys.projectId, projectId), + orderBy: [desc(apiKeys.isPublic), asc(apiKeys.createdAt)] + }) + ) + ), - updateApiKey: dbService.makeQuery( - (execute, apiKey: Omit, "id"> & { id: string }) => - execute(async (db) => { - await db - .update(apiKeys) - .set(apiKey) - .where(eq(apiKeys.id, apiKey.id)); - return { id: apiKey.id }; - }) - ), + updateApiKey: dbService.makeQuery( + (execute, apiKey: Omit, 'id'> & { id: string }) => + execute(async (db) => { + await db + .update(apiKeys) + .set(apiKey) + .where(eq(apiKeys.id, apiKey.id)); + return { id: apiKey.id }; + }) + ), - deleteApiKey: dbService.makeQuery((execute, id: string) => - execute(async (db) => { - await db.delete(apiKeys).where(eq(apiKeys.id, id)); - return { id }; - }) - ), - }; - }), + deleteApiKey: dbService.makeQuery((execute, id: string) => + execute(async (db) => { + await db.delete(apiKeys).where(eq(apiKeys.id, id)); + return { id }; + }) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/checkout-session.repository.ts b/apps/web/lib/repositories/checkout-session.repository.ts index dc2ca5bbd..bbc1fec6a 100644 --- a/apps/web/lib/repositories/checkout-session.repository.ts +++ b/apps/web/lib/repositories/checkout-session.repository.ts @@ -1,35 +1,49 @@ -import { Db } from "@/lib/effect/db"; -import { CheckoutSession, checkoutSessions, eq, InsertCheckoutSession } from "@voidhash/db"; -import { Effect } from "effect"; +import { + type CheckoutSession, + checkoutSessions, + eq, + type InsertCheckoutSession +} from '@voidhash/db'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class CheckoutSessionRepository extends Effect.Service()( - "CheckoutSessionRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createCheckoutSession: dbService.makeQuery( - (execute, session: InsertCheckoutSession) => - execute(async (db) => - await db.insert(checkoutSessions).values(session) - ) - ), - getCheckoutSessionById: dbService.makeQuery( - (execute, id: string) => - execute(async (db) => - await db.query.checkoutSessions.findFirst({ where: eq(checkoutSessions.id, id) }) - ) - ), - updateCheckoutSession: dbService.makeQuery( - (execute, session: Omit, "id"> & { id: string }) => - execute(async (db) => - await db.update(checkoutSessions).set(session).where(eq(checkoutSessions.id, session.id)) - ) - ), - }; - }), + 'CheckoutSessionRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createCheckoutSession: dbService.makeQuery( + (execute, session: InsertCheckoutSession) => + execute( + async (db) => await db.insert(checkoutSessions).values(session) + ) + ), + getCheckoutSessionById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.checkoutSessions.findFirst({ + where: eq(checkoutSessions.id, id) + }) + ) + ), + updateCheckoutSession: dbService.makeQuery( + ( + execute, + session: Omit, 'id'> & { id: string } + ) => + execute( + async (db) => + await db + .update(checkoutSessions) + .set(session) + .where(eq(checkoutSessions.id, session.id)) + ) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/customer-unlocked-perk.repository.ts b/apps/web/lib/repositories/customer-unlocked-perk.repository.ts index 9baa34f1d..4ccfd08cc 100644 --- a/apps/web/lib/repositories/customer-unlocked-perk.repository.ts +++ b/apps/web/lib/repositories/customer-unlocked-perk.repository.ts @@ -1,46 +1,46 @@ -import { Db } from "@/lib/effect/db"; import { - eq, - InsertCustomerUnlockedPerk, - CustomerUnlockedPerk, - customerUnlockedPerks, -} from "@voidhash/db"; -import { Effect } from "effect"; + type CustomerUnlockedPerk, + customerUnlockedPerks, + eq, + type InsertCustomerUnlockedPerk +} from '@voidhash/db'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class CustomerUnlockedPerkRepository extends Effect.Service()( - "CustomerUnlockedPerkRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createCustomerUnlockedPerk: dbService.makeQuery( - (execute, customerUnlockedPerk: InsertCustomerUnlockedPerk) => - execute(async (db) => { - await db - .insert(customerUnlockedPerks) - .values(customerUnlockedPerk); - return { id: customerUnlockedPerk.id }; - }) - ), - updateCustomerUnlockedPerk: dbService.makeQuery( - ( - execute, - customerUnlockedPerk: Omit, "id"> & { - id: string; - } - ) => - execute(async (db) => { - await db - .update(customerUnlockedPerks) - .set(customerUnlockedPerk) - .where(eq(customerUnlockedPerks.id, customerUnlockedPerk.id)); - return { id: customerUnlockedPerk.id }; - }) - ), - }; - }), + 'CustomerUnlockedPerkRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createCustomerUnlockedPerk: dbService.makeQuery( + (execute, customerUnlockedPerk: InsertCustomerUnlockedPerk) => + execute(async (db) => { + await db + .insert(customerUnlockedPerks) + .values(customerUnlockedPerk); + return { id: customerUnlockedPerk.id }; + }) + ), + updateCustomerUnlockedPerk: dbService.makeQuery( + ( + execute, + customerUnlockedPerk: Omit, 'id'> & { + id: string; + } + ) => + execute(async (db) => { + await db + .update(customerUnlockedPerks) + .set(customerUnlockedPerk) + .where(eq(customerUnlockedPerks.id, customerUnlockedPerk.id)); + return { id: customerUnlockedPerk.id }; + }) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/customer.repository.ts b/apps/web/lib/repositories/customer.repository.ts index 3de439c27..8cbf46781 100644 --- a/apps/web/lib/repositories/customer.repository.ts +++ b/apps/web/lib/repositories/customer.repository.ts @@ -1,169 +1,169 @@ -import { Db } from "@/lib/effect/db"; import { - and, - Customer, - customers, - customerUnlockedPerks, - CustomerTypeValue, - eq, - externalCustomerIdentifiers, - InsertCustomer, - InsertCustomerUnlockedPerk, - purchases, -} from "@voidhash/db"; -import { EnvironmentValue } from "@voidhash/lib/constants"; -import { Effect } from "effect"; + and, + type Customer, + type CustomerTypeValue, + customers, + customerUnlockedPerks, + eq, + externalCustomerIdentifiers, + type InsertCustomer, + type InsertCustomerUnlockedPerk, + purchases +} from '@voidhash/db'; +import type { EnvironmentValue } from '@voidhash/lib/constants'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class CustomerRepository extends Effect.Service()( - "CustomerRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createCustomer: dbService.makeQuery( - (execute, customer: InsertCustomer) => - execute(async (db) => { - await db.insert(customers).values(customer); - return { id: customer.id }; - }) - ), + 'CustomerRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createCustomer: dbService.makeQuery( + (execute, customer: InsertCustomer) => + execute(async (db) => { + await db.insert(customers).values(customer); + return { id: customer.id }; + }) + ), - getCustomers: dbService.makeQuery( - ( - execute, - { - projectId, - environment, - type, - }: { - projectId: string; - environment: EnvironmentValue; - type: CustomerTypeValue | null; - } - ) => - execute( - async (db) => - await db.query.customers.findMany({ - where: and( - eq(customers.projectId, projectId), - type !== null ? eq(customers.type, type) : undefined, - eq(customers.environment, environment) - ), - }) - ) - ), + getCustomers: dbService.makeQuery( + ( + execute, + { + projectId, + environment, + type + }: { + projectId: string; + environment: EnvironmentValue; + type: CustomerTypeValue | null; + } + ) => + execute( + async (db) => + await db.query.customers.findMany({ + where: and( + eq(customers.projectId, projectId), + type !== null ? eq(customers.type, type) : undefined, + eq(customers.environment, environment) + ) + }) + ) + ), - getCustomerById: dbService.makeQuery((execute, id: string) => - execute( - async (db) => - await db.query.customers.findFirst({ - where: eq(customers.id, id), - }) - ) - ), + getCustomerById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.customers.findFirst({ + where: eq(customers.id, id) + }) + ) + ), - getCustomerByAppUserId: dbService.makeQuery( - ( - execute, - { - projectId, - appUserId, - environment, - }: { - projectId: string; - appUserId: string; - environment: EnvironmentValue; - } - ) => - execute( - async (db) => - await db.query.customers.findFirst({ - where: and( - eq(customers.projectId, projectId), - eq(customers.appUserId, appUserId), - eq(customers.environment, environment) - ), - }) - ) - ), + getCustomerByAppUserId: dbService.makeQuery( + ( + execute, + { + projectId, + appUserId, + environment + }: { + projectId: string; + appUserId: string; + environment: EnvironmentValue; + } + ) => + execute( + async (db) => + await db.query.customers.findFirst({ + where: and( + eq(customers.projectId, projectId), + eq(customers.appUserId, appUserId), + eq(customers.environment, environment) + ) + }) + ) + ), - getCustomerByExternalIdentifier: dbService.makeQuery( - ( - execute, - { - projectId, - serviceId, - identifier, - environment, - }: { - projectId: string; - serviceId: string; - identifier: string; - environment: EnvironmentValue; - } - ) => - execute( - async (db) => - await db - .select() - .from(customers) - .innerJoin( - externalCustomerIdentifiers, - eq(customers.id, externalCustomerIdentifiers.customerId) - ) - .where( - and( - eq(customers.projectId, projectId), - eq(externalCustomerIdentifiers.serviceId, serviceId), - eq(externalCustomerIdentifiers.identifier, identifier), - eq(customers.environment, environment) - ) - ) - ) - ), + getCustomerByExternalIdentifier: dbService.makeQuery( + ( + execute, + { + projectId, + serviceId, + identifier, + environment + }: { + projectId: string; + serviceId: string; + identifier: string; + environment: EnvironmentValue; + } + ) => + execute( + async (db) => + await db + .select() + .from(customers) + .innerJoin( + externalCustomerIdentifiers, + eq(customers.id, externalCustomerIdentifiers.customerId) + ) + .where( + and( + eq(customers.projectId, projectId), + eq(externalCustomerIdentifiers.serviceId, serviceId), + eq(externalCustomerIdentifiers.identifier, identifier), + eq(customers.environment, environment) + ) + ) + ) + ), - createCustomerUnlockedPerks: dbService.makeQuery( - (execute, input: InsertCustomerUnlockedPerk[]) => - execute(async (db) => { - await db.insert(customerUnlockedPerks).values(input); - return { ids: input.map((perk) => perk.id) }; - }) - ), + createCustomerUnlockedPerks: dbService.makeQuery( + (execute, input: InsertCustomerUnlockedPerk[]) => + execute(async (db) => { + await db.insert(customerUnlockedPerks).values(input); + return { ids: input.map((perk) => perk.id) }; + }) + ), - getCustomersUnlockedPerks: dbService.makeQuery( - (execute, customerId: string) => - execute( - async (db) => - await db.query.customerUnlockedPerks.findMany({ - where: eq(customerUnlockedPerks.customerId, customerId), - }) - ) - ), + getCustomersUnlockedPerks: dbService.makeQuery( + (execute, customerId: string) => + execute( + async (db) => + await db.query.customerUnlockedPerks.findMany({ + where: eq(customerUnlockedPerks.customerId, customerId) + }) + ) + ), - getCustomerPurchases: dbService.makeQuery( - (execute, customerId: string) => - execute( - async (db) => - await db.query.purchases.findMany({ - where: eq(purchases.customerId, customerId), - }) - ) - ), + getCustomerPurchases: dbService.makeQuery( + (execute, customerId: string) => + execute( + async (db) => + await db.query.purchases.findMany({ + where: eq(purchases.customerId, customerId) + }) + ) + ), - updateCustomer: dbService.makeQuery( - (execute, { id, ...customer }: Partial & { id: string }) => - execute(async (db) => { - await db - .update(customers) - .set(customer) - .where(eq(customers.id, id)); - return { id }; - }) - ), - }; - }), + updateCustomer: dbService.makeQuery( + (execute, customer: Omit, 'id'> & { id: string }) => + execute(async (db) => { + await db + .update(customers) + .set(customer) + .where(eq(customers.id, customer.id)); + return { id: customer.id }; + }) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/organization.repository.ts b/apps/web/lib/repositories/organization.repository.ts index ae52e3ddf..1899953e5 100644 --- a/apps/web/lib/repositories/organization.repository.ts +++ b/apps/web/lib/repositories/organization.repository.ts @@ -1,36 +1,34 @@ -import { Db } from "@/lib/effect/db"; -import { eq, organization } from "@voidhash/db"; -import { Effect } from "effect"; +import { eq, organization } from '@voidhash/db'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class OrganizationRepository extends Effect.Service()( - "OrganizationRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - getOrganizationBySlug: dbService.makeQuery( - (execute, slug: string) => - execute( - async (db) => - await db.query.organization.findFirst({ - where: eq(organization.slug, slug), - }) - ) - ), + 'OrganizationRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + getOrganizationBySlug: dbService.makeQuery((execute, slug: string) => + execute( + async (db) => + await db.query.organization.findFirst({ + where: eq(organization.slug, slug) + }) + ) + ), - getOrganizationById: dbService.makeQuery( - (execute, id: string ) => - execute( - async (db) => - await db.query.organization.findFirst({ - where: eq(organization.id, id), - }) - ) - ), - }; - }), + getOrganizationById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.organization.findFirst({ + where: eq(organization.id, id) + }) + ) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/payment-provider-configuration-product.repository.ts b/apps/web/lib/repositories/payment-provider-configuration-product.repository.ts index c8decd9e4..fb40a61f1 100644 --- a/apps/web/lib/repositories/payment-provider-configuration-product.repository.ts +++ b/apps/web/lib/repositories/payment-provider-configuration-product.repository.ts @@ -1,254 +1,254 @@ -import { Db } from "@/lib/effect/db"; import { - eq, - and, - asc, - not, - paymentProviderConfigurationProducts, - paymentProviderConfigurations, - InsertPaymentProviderConfigurationProduct, -} from "@voidhash/db"; -import { Effect } from "effect"; -import { EnvironmentValue } from "@voidhash/lib/constants"; + and, + asc, + eq, + type InsertPaymentProviderConfigurationProduct, + not, + paymentProviderConfigurationProducts, + paymentProviderConfigurations +} from '@voidhash/db'; +import type { EnvironmentValue } from '@voidhash/lib/constants'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class PaymentProviderConfigurationProductRepository extends Effect.Service()( - "PaymentProviderConfigurationProductRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createPaymentProviderProduct: dbService.makeQuery( - ( - execute, - providerProduct: InsertPaymentProviderConfigurationProduct - ) => - execute( - async (db) => - await db - .insert(paymentProviderConfigurationProducts) - .values(providerProduct) - ) - ), + 'PaymentProviderConfigurationProductRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createPaymentProviderProduct: dbService.makeQuery( + ( + execute, + providerProduct: InsertPaymentProviderConfigurationProduct + ) => + execute( + async (db) => + await db + .insert(paymentProviderConfigurationProducts) + .values(providerProduct) + ) + ), - updatePaymentProviderProduct: dbService.makeQuery( - ( - execute, - { - id, - newProviderProductKey, - configuration, - }: { - id: string; - newProviderProductKey: string; - configuration: object; - } - ) => - execute( - async (db) => - await db - .update(paymentProviderConfigurationProducts) - .set({ - providerProductKey: newProviderProductKey, - configuration: configuration, - }) - .where(and(eq(paymentProviderConfigurationProducts.id, id))) - ) - ), + updatePaymentProviderProduct: dbService.makeQuery( + ( + execute, + { + id, + newProviderProductKey, + configuration + }: { + id: string; + newProviderProductKey: string; + configuration: object; + } + ) => + execute( + async (db) => + await db + .update(paymentProviderConfigurationProducts) + .set({ + providerProductKey: newProviderProductKey, + configuration + }) + .where(and(eq(paymentProviderConfigurationProducts.id, id))) + ) + ), - deactivateOtherProviderProducts: dbService.makeQuery( - ( - execute, - { - productId, - paymentProviderConfigurationId, - excludeProviderProductKey, - }: { - productId: string; - paymentProviderConfigurationId: string; - excludeProviderProductKey?: string; - } - ) => - execute( - async (db) => - await db - .update(paymentProviderConfigurationProducts) - .set({ isActive: false }) - .where( - and( - eq( - paymentProviderConfigurationProducts.productId, - productId - ), - eq( - paymentProviderConfigurationProducts.paymentProviderConfigurationId, - paymentProviderConfigurationId - ), - excludeProviderProductKey - ? not( - eq( - paymentProviderConfigurationProducts.providerProductKey, - excludeProviderProductKey - ) - ) - : undefined - ) - ) - ) - ), + deactivateOtherProviderProducts: dbService.makeQuery( + ( + execute, + { + productId, + paymentProviderConfigurationId, + excludeProviderProductKey + }: { + productId: string; + paymentProviderConfigurationId: string; + excludeProviderProductKey?: string; + } + ) => + execute( + async (db) => + await db + .update(paymentProviderConfigurationProducts) + .set({ isActive: false }) + .where( + and( + eq( + paymentProviderConfigurationProducts.productId, + productId + ), + eq( + paymentProviderConfigurationProducts.paymentProviderConfigurationId, + paymentProviderConfigurationId + ), + excludeProviderProductKey + ? not( + eq( + paymentProviderConfigurationProducts.providerProductKey, + excludeProviderProductKey + ) + ) + : undefined + ) + ) + ) + ), - setActivePaymentProviderProduct: dbService.makeQuery( - ( - execute, - { - productId, - paymentProviderConfigurationId, - providerProductKey, - }: { - productId: string; - paymentProviderConfigurationId: string; - providerProductKey: string; - } - ) => - execute( - async (db) => - await db - .update(paymentProviderConfigurationProducts) - .set({ isActive: true }) - .where( - and( - eq( - paymentProviderConfigurationProducts.productId, - productId - ), - eq( - paymentProviderConfigurationProducts.paymentProviderConfigurationId, - paymentProviderConfigurationId - ), - eq( - paymentProviderConfigurationProducts.providerProductKey, - providerProductKey - ) - ) - ) - ) - ), + setActivePaymentProviderProduct: dbService.makeQuery( + ( + execute, + { + productId, + paymentProviderConfigurationId, + providerProductKey + }: { + productId: string; + paymentProviderConfigurationId: string; + providerProductKey: string; + } + ) => + execute( + async (db) => + await db + .update(paymentProviderConfigurationProducts) + .set({ isActive: true }) + .where( + and( + eq( + paymentProviderConfigurationProducts.productId, + productId + ), + eq( + paymentProviderConfigurationProducts.paymentProviderConfigurationId, + paymentProviderConfigurationId + ), + eq( + paymentProviderConfigurationProducts.providerProductKey, + providerProductKey + ) + ) + ) + ) + ), - deletePaymentProviderProduct: dbService.makeQuery( - ( - execute, - { - productId, - paymentProviderConfigurationId, - providerProductKey, - }: { - productId: string; - paymentProviderConfigurationId: string; - providerProductKey: string; - } - ) => - execute( - async (db) => - await db - .delete(paymentProviderConfigurationProducts) - .where( - and( - eq( - paymentProviderConfigurationProducts.productId, - productId - ), - eq( - paymentProviderConfigurationProducts.paymentProviderConfigurationId, - paymentProviderConfigurationId - ), - eq( - paymentProviderConfigurationProducts.providerProductKey, - providerProductKey - ) - ) - ) - ) - ), + deletePaymentProviderProduct: dbService.makeQuery( + ( + execute, + { + productId, + paymentProviderConfigurationId, + providerProductKey + }: { + productId: string; + paymentProviderConfigurationId: string; + providerProductKey: string; + } + ) => + execute( + async (db) => + await db + .delete(paymentProviderConfigurationProducts) + .where( + and( + eq( + paymentProviderConfigurationProducts.productId, + productId + ), + eq( + paymentProviderConfigurationProducts.paymentProviderConfigurationId, + paymentProviderConfigurationId + ), + eq( + paymentProviderConfigurationProducts.providerProductKey, + providerProductKey + ) + ) + ) + ) + ), - getProviderProductsByProductId: dbService.makeQuery( - (execute, productId: string) => - execute( - async (db) => - await db.query.paymentProviderConfigurationProducts.findMany({ - where: eq( - paymentProviderConfigurationProducts.productId, - productId - ), - orderBy: [ - asc(paymentProviderConfigurationProducts.createdAt), - ], - }) - ) - ), + getProviderProductsByProductId: dbService.makeQuery( + (execute, productId: string) => + execute( + async (db) => + await db.query.paymentProviderConfigurationProducts.findMany({ + where: eq( + paymentProviderConfigurationProducts.productId, + productId + ), + orderBy: [asc(paymentProviderConfigurationProducts.createdAt)] + }) + ) + ), - getProviderProductById: dbService.makeQuery((execute, id: string) => - execute( - async (db) => - await db.query.paymentProviderConfigurationProducts.findFirst({ - where: eq(paymentProviderConfigurationProducts.id, id), - }) - ) - ), + getProviderProductById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.paymentProviderConfigurationProducts.findFirst({ + where: eq(paymentProviderConfigurationProducts.id, id) + }) + ) + ), - getProviderProductByPrimaryKey: dbService.makeQuery( - ( - execute, - { - paymentProviderConfigurationId, - providerProductKey, - environment, - }: { - paymentProviderConfigurationId: string; - providerProductKey: string; - environment: EnvironmentValue; - } - ) => - execute(async (db) => { - const result = await db - .select() - .from(paymentProviderConfigurationProducts) - .innerJoin( - paymentProviderConfigurations, - eq( - paymentProviderConfigurationProducts.paymentProviderConfigurationId, - paymentProviderConfigurations.id - ) - ) - .where( - and( - eq( - paymentProviderConfigurationProducts.paymentProviderConfigurationId, - paymentProviderConfigurationId - ), - eq( - paymentProviderConfigurationProducts.providerProductKey, - providerProductKey - ), - eq( - paymentProviderConfigurationProducts.environment, - environment - ) - ) - ); + getProviderProductByPrimaryKey: dbService.makeQuery( + ( + execute, + { + paymentProviderConfigurationId, + providerProductKey, + environment + }: { + paymentProviderConfigurationId: string; + providerProductKey: string; + environment: EnvironmentValue; + } + ) => + execute(async (db) => { + const result = await db + .select() + .from(paymentProviderConfigurationProducts) + .innerJoin( + paymentProviderConfigurations, + eq( + paymentProviderConfigurationProducts.paymentProviderConfigurationId, + paymentProviderConfigurations.id + ) + ) + .where( + and( + eq( + paymentProviderConfigurationProducts.paymentProviderConfigurationId, + paymentProviderConfigurationId + ), + eq( + paymentProviderConfigurationProducts.providerProductKey, + providerProductKey + ), + eq( + paymentProviderConfigurationProducts.environment, + environment + ) + ) + ); - const row = result[0]; - if (!row) return null; + const row = result[0]; + if (!row) { + return null; + } - return { - ...row.payment_provider_configuration_product, - projectId: row.payment_provider_configuration.projectId, - providerId: row.payment_provider_configuration.providerId, - }; - }) - ), - }; - }), + return { + ...row.payment_provider_configuration_product, + projectId: row.payment_provider_configuration.projectId, + providerId: row.payment_provider_configuration.providerId + }; + }) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/payment-provider.repository.ts b/apps/web/lib/repositories/payment-provider.repository.ts index 20158344a..0218e42a6 100644 --- a/apps/web/lib/repositories/payment-provider.repository.ts +++ b/apps/web/lib/repositories/payment-provider.repository.ts @@ -1,139 +1,160 @@ -import { Db } from "@/lib/effect/db"; -import { and, eq, isNull, ne, InsertPaymentProviderConfiguration, paymentProviderConfigurations } from "@voidhash/db"; -import { Effect } from "effect"; +import { + and, + eq, + type InsertPaymentProviderConfiguration, + isNull, + ne, + paymentProviderConfigurations +} from '@voidhash/db'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; -export class PaymentProviderRepository extends Effect.Service()( - "PaymentProviderRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createPaymentProviderConfiguration: dbService.makeQuery( - (execute, configuration: InsertPaymentProviderConfiguration) => - execute(async (db) => - await db.insert(paymentProviderConfigurations).values(configuration) - ) - ), +export class PaymentProviderConfigurationRepository extends Effect.Service()( + 'PaymentProviderConfigurationRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createPaymentProviderConfiguration: dbService.makeQuery( + (execute, configuration: InsertPaymentProviderConfiguration) => + execute( + async (db) => + await db + .insert(paymentProviderConfigurations) + .values(configuration) + ) + ), - getPaymentProviderConfigurations: dbService.makeQuery( - (execute, projectId: string) => - execute( - async (db) => - await db.query.paymentProviderConfigurations.findMany({ - where: and( - eq(paymentProviderConfigurations.projectId, projectId), - isNull(paymentProviderConfigurations.deletedAt) - ), - }) - ) - ), + getPaymentProviderConfigurations: dbService.makeQuery( + (execute, projectId: string) => + execute( + async (db) => + await db.query.paymentProviderConfigurations.findMany({ + where: and( + eq(paymentProviderConfigurations.projectId, projectId), + isNull(paymentProviderConfigurations.deletedAt) + ) + }) + ) + ), - getPaymentProviderConfigurationById: dbService.makeQuery( - (execute, id: string) => - execute( - async (db) => - await db.query.paymentProviderConfigurations.findFirst({ - where: eq(paymentProviderConfigurations.id, id), - }) - ) - ), + getPaymentProviderConfigurationById: dbService.makeQuery( + (execute, id: string) => + execute( + async (db) => + await db.query.paymentProviderConfigurations.findFirst({ + where: eq(paymentProviderConfigurations.id, id) + }) + ) + ), - getExistingPaymentProviderConfigurationByProviderId: dbService.makeQuery( - ( - execute, - input: { - projectId: string; - providerId: string; - } - ) => - execute( - async (db) => - await db.query.paymentProviderConfigurations.findFirst({ - where: and( - eq(paymentProviderConfigurations.projectId, input.projectId), - eq(paymentProviderConfigurations.providerId, input.providerId), - isNull(paymentProviderConfigurations.deletedAt) - ), - }) - ) - ), + getExistingPaymentProviderConfigurationByProviderId: + dbService.makeQuery( + ( + execute, + input: { + projectId: string; + providerId: string; + } + ) => + execute( + async (db) => + await db.query.paymentProviderConfigurations.findFirst({ + where: and( + eq( + paymentProviderConfigurations.projectId, + input.projectId + ), + eq( + paymentProviderConfigurations.providerId, + input.providerId + ), + isNull(paymentProviderConfigurations.deletedAt) + ) + }) + ) + ), - updatePaymentProviderConfiguration: dbService.makeQuery( - ( - execute, - input: { - id: string; - configuration?: Record; - enabled?: boolean; - name?: string; - paymentProviderKey?: string; - } - ) => - execute( - async (db) => - await db - .update(paymentProviderConfigurations) - .set({ - ...(input.configuration !== undefined && { configuration: input.configuration }), - ...(input.enabled !== undefined && { enabled: input.enabled }), - ...(input.name !== undefined && { name: input.name }), - ...(input.paymentProviderKey !== undefined && { paymentProviderKey: input.paymentProviderKey }), - }) - .where(eq(paymentProviderConfigurations.id, input.id)) - ) - ), + updatePaymentProviderConfiguration: dbService.makeQuery( + ( + execute, + input: { + id: string; + configuration?: Record; + enabled?: boolean; + name?: string; + paymentProviderKey?: string; + } + ) => + execute( + async (db) => + await db + .update(paymentProviderConfigurations) + .set({ + ...(input.configuration !== undefined && { + configuration: input.configuration + }), + ...(input.enabled !== undefined && { + enabled: input.enabled + }), + ...(input.name !== undefined && { name: input.name }), + ...(input.paymentProviderKey !== undefined && { + paymentProviderKey: input.paymentProviderKey + }) + }) + .where(eq(paymentProviderConfigurations.id, input.id)) + ) + ), - deletePaymentProviderConfiguration: dbService.makeQuery( - (execute, id: string) => - execute( - async (db) => - await db - .update(paymentProviderConfigurations) - .set({ - deletedAt: new Date(), - }) - .where(eq(paymentProviderConfigurations.id, id)) - ) - ), + deletePaymentProviderConfiguration: dbService.makeQuery( + (execute, id: string) => + execute( + async (db) => + await db + .update(paymentProviderConfigurations) + .set({ + deletedAt: new Date() + }) + .where(eq(paymentProviderConfigurations.id, id)) + ) + ), - checkPaymentProviderKeyAvailability: dbService.makeQuery( - ( - execute, - input: { - key: string; - providerId: string; - projectId: string; - excludeId?: string; - } - ) => - execute( - async (db) => { - const conditions = [ - eq(paymentProviderConfigurations.projectId, input.projectId), - eq(paymentProviderConfigurations.providerId, input.providerId), - eq(paymentProviderConfigurations.paymentProviderKey, input.key), - isNull(paymentProviderConfigurations.deletedAt) - ]; + checkPaymentProviderKeyAvailability: dbService.makeQuery( + ( + execute, + input: { + key: string; + providerId: string; + projectId: string; + excludeId?: string; + } + ) => + execute(async (db) => { + const conditions = [ + eq(paymentProviderConfigurations.projectId, input.projectId), + eq(paymentProviderConfigurations.providerId, input.providerId), + eq(paymentProviderConfigurations.paymentProviderKey, input.key), + isNull(paymentProviderConfigurations.deletedAt) + ]; - if (input.excludeId) { - conditions.push( - ne(paymentProviderConfigurations.id, input.excludeId) - ); - } + if (input.excludeId) { + conditions.push( + ne(paymentProviderConfigurations.id, input.excludeId) + ); + } - const existingConfigurations = await db - .select() - .from(paymentProviderConfigurations) - .where(and(...conditions)); + const existingConfigurations = await db + .select() + .from(paymentProviderConfigurations) + .where(and(...conditions)); - return existingConfigurations.length === 0; - } - ) - ), - }; - }), + return existingConfigurations.length === 0; + }) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/paywall-location.repository.ts b/apps/web/lib/repositories/paywall-location.repository.ts index 7fa814ed9..add1a69fb 100644 --- a/apps/web/lib/repositories/paywall-location.repository.ts +++ b/apps/web/lib/repositories/paywall-location.repository.ts @@ -1,77 +1,82 @@ -import { Db } from "@/lib/effect/db"; -import { and, eq, InsertPaywallLocation, paywallLocations } from "@voidhash/db"; -import { EnvironmentValue } from "@voidhash/lib/constants"; -import { Effect } from "effect"; +import { + and, + eq, + type InsertPaywallLocation, + paywallLocations +} from '@voidhash/db'; +import type { EnvironmentValue } from '@voidhash/lib/constants'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class PaywallLocationRepository extends Effect.Service()( - "PaywallLocationRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createPaywallLocation: dbService.makeQuery( - (execute, paywallLocation: InsertPaywallLocation) => - execute( - async (db) => - await db.insert(paywallLocations).values(paywallLocation) - ) - ), + 'PaywallLocationRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createPaywallLocation: dbService.makeQuery( + (execute, paywallLocation: InsertPaywallLocation) => + execute( + async (db) => + await db.insert(paywallLocations).values(paywallLocation) + ) + ), - getPaywallLocations: dbService.makeQuery( - ( - execute, - input: { projectId: string; environment: EnvironmentValue } - ) => - execute( - async (db) => - await db.query.paywallLocations.findMany({ - where: and( - eq(paywallLocations.projectId, input.projectId), - eq(paywallLocations.environment, input.environment) - ), - }) - ) - ), + getPaywallLocations: dbService.makeQuery( + ( + execute, + input: { projectId: string; environment: EnvironmentValue } + ) => + execute( + async (db) => + await db.query.paywallLocations.findMany({ + where: and( + eq(paywallLocations.projectId, input.projectId), + eq(paywallLocations.environment, input.environment) + ) + }) + ) + ), - getPaywallLocationById: dbService.makeQuery((execute, id: string) => - execute( - async (db) => - await db.query.paywallLocations.findFirst({ - where: eq(paywallLocations.id, id), - }) - ) - ), + getPaywallLocationById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.paywallLocations.findFirst({ + where: eq(paywallLocations.id, id) + }) + ) + ), - getPaywallLocationBySlug: dbService.makeQuery( - ( - execute, - input: { - slug: string; - projectId: string; - environment: EnvironmentValue; - } - ) => - execute( - async (db) => - await db.query.paywallLocations.findFirst({ - where: and( - eq(paywallLocations.slug, input.slug), - eq(paywallLocations.projectId, input.projectId), - eq(paywallLocations.environment, input.environment) - ), - }) - ) - ), + getPaywallLocationBySlug: dbService.makeQuery( + ( + execute, + input: { + slug: string; + projectId: string; + environment: EnvironmentValue; + } + ) => + execute( + async (db) => + await db.query.paywallLocations.findFirst({ + where: and( + eq(paywallLocations.slug, input.slug), + eq(paywallLocations.projectId, input.projectId), + eq(paywallLocations.environment, input.environment) + ) + }) + ) + ), - deletePaywallLocation: dbService.makeQuery((execute, id: string) => - execute(async (db) => - db.delete(paywallLocations).where(eq(paywallLocations.id, id)) - ) - ), - }; - }), + deletePaywallLocation: dbService.makeQuery((execute, id: string) => + execute(async (db) => + db.delete(paywallLocations).where(eq(paywallLocations.id, id)) + ) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/paywall.repository.ts b/apps/web/lib/repositories/paywall.repository.ts index 55e6ecae4..fbd5545b9 100644 --- a/apps/web/lib/repositories/paywall.repository.ts +++ b/apps/web/lib/repositories/paywall.repository.ts @@ -1,179 +1,211 @@ -import { Db } from "@/lib/effect/db"; import { - eq, - and, - asc, - paywalls, - paywallProducts, - InsertPaywall, - InsertPaywallProduct, - paywallLocations, - inArray, - products, -} from "@voidhash/db"; -import { Effect } from "effect"; -import { EnvironmentValue } from "@voidhash/lib/constants"; + and, + asc, + eq, + type InsertPaywall, + type InsertPaywallProduct, + inArray, + paywallLocations, + paywallProducts, + paywalls, + products +} from '@voidhash/db'; +import type { EnvironmentValue } from '@voidhash/lib/constants'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class PaywallRepository extends Effect.Service()( - "PaywallRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createPaywall: dbService.makeQuery((execute, paywall: InsertPaywall) => - execute(async (db) => await db.insert(paywalls).values(paywall)) - ), + 'PaywallRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createPaywall: dbService.makeQuery((execute, paywall: InsertPaywall) => + execute(async (db) => await db.insert(paywalls).values(paywall)) + ), - getPaywallById: dbService.makeQuery((execute, id: string) => - execute( - async (db) => - await db.query.paywalls.findFirst({ - where: eq(paywalls.id, id), - }) - ) - ), + getPaywallById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.paywalls.findFirst({ + where: eq(paywalls.id, id) + }) + ) + ), - getPaywallWithProductsByLocationSlug: dbService.makeQuery( - ( - execute, - input: { - locationSlug: string; - environment: EnvironmentValue; - } - ) => - execute( - async (db) => - await db.query.paywallLocations.findFirst({ - where: and( - eq(paywallLocations.slug, input.locationSlug), - eq(paywallLocations.environment, input.environment) - ), - with: { - defaultPaywall: { - with: { - paywallProducts: { - with: { - product: true, - }, - orderBy: [asc(paywallProducts.order)], - }, - }, - }, - }, - }) - ) - ), + getPaywallWithProductsByLocationSlug: dbService.makeQuery( + ( + execute, + input: { + locationSlug: string; + environment: EnvironmentValue; + } + ) => + execute( + async (db) => + await db.query.paywallLocations.findFirst({ + where: and( + eq(paywallLocations.slug, input.locationSlug), + eq(paywallLocations.environment, input.environment) + ), + with: { + defaultPaywall: { + with: { + paywallProducts: { + with: { + product: true + }, + orderBy: [asc(paywallProducts.order)] + } + } + } + } + }) + ) + ), - getPaywalls: dbService.makeQuery( - ( - execute, - input: { projectId: string; environment: EnvironmentValue } - ) => - execute( - async (db) => - await db.query.paywalls.findMany({ - where: and( - eq(paywalls.projectId, input.projectId), - eq(paywalls.environment, input.environment) - ), - }) - ) - ), + getPaywalls: dbService.makeQuery( + ( + execute, + input: { projectId: string; environment: EnvironmentValue } + ) => + execute( + async (db) => + await db.query.paywalls.findMany({ + where: and( + eq(paywalls.projectId, input.projectId), + eq(paywalls.environment, input.environment) + ) + }) + ) + ), - updatePaywall: dbService.makeQuery( - (execute, { id, name }: { id: string; name: string }) => - execute( - async (db) => - await db - .update(paywalls) - .set({ - name, - updatedAt: new Date(), - }) - .where(eq(paywalls.id, id)) - ) - ), + getPaywallsWithProductsAndPaymentProviderConfigurations: + dbService.makeQuery( + ( + execute, + input: { + projectId: string; + environment: EnvironmentValue; + } + ) => + execute( + async (db) => + await db.query.paywalls.findMany({ + where: and( + eq(paywalls.projectId, input.projectId), + eq(paywalls.environment, input.environment) + ), + with: { + paywallProducts: { + with: { + product: { + with: { + paymentProviderConfigurationProducts: true + } + } + }, + orderBy: [asc(paywallProducts.order)] + } + } + }) + ) + ), - deletePaywall: dbService.makeQuery((execute, id: string) => - execute( - async (db) => await db.delete(paywalls).where(eq(paywalls.id, id)) - ) - ), + updatePaywall: dbService.makeQuery( + (execute, { id, name }: { id: string; name: string }) => + execute( + async (db) => + await db + .update(paywalls) + .set({ + name, + updatedAt: new Date() + }) + .where(eq(paywalls.id, id)) + ) + ), - getPaywallProducts: dbService.makeQuery((execute, paywallId: string) => - execute( - async (db) => - await db.query.paywallProducts.findMany({ - where: eq(paywallProducts.paywallId, paywallId), - with: { - product: { - columns: { - name: true, - }, - }, - }, - orderBy: [asc(paywallProducts.order)], - }) - ) - ), + deletePaywall: dbService.makeQuery((execute, id: string) => + execute( + async (db) => await db.delete(paywalls).where(eq(paywalls.id, id)) + ) + ), - getPaywallProductById: dbService.makeQuery( - (execute, paywallProductId: string) => - execute( - async (db) => - await db.query.paywallProducts.findFirst({ - where: eq(paywallProducts.id, paywallProductId), - with: { - product: true, - }, - }) - ) - ), + getPaywallProducts: dbService.makeQuery((execute, paywallId: string) => + execute( + async (db) => + await db.query.paywallProducts.findMany({ + where: eq(paywallProducts.paywallId, paywallId), + with: { + product: { + columns: { + name: true + } + } + }, + orderBy: [asc(paywallProducts.order)] + }) + ) + ), - createPaywallProduct: dbService.makeQuery( - (execute, paywallProduct: InsertPaywallProduct) => - execute( - async (db) => - await db.insert(paywallProducts).values(paywallProduct) - ) - ), + getPaywallProductById: dbService.makeQuery( + (execute, paywallProductId: string) => + execute( + async (db) => + await db.query.paywallProducts.findFirst({ + where: eq(paywallProducts.id, paywallProductId), + with: { + product: true + } + }) + ) + ), - deletePaywallProducts: dbService.makeQuery( - (execute, paywallId: string) => - execute( - async (db) => - await db - .delete(paywallProducts) - .where(eq(paywallProducts.paywallId, paywallId)) - ) - ), + createPaywallProduct: dbService.makeQuery( + (execute, paywallProduct: InsertPaywallProduct) => + execute( + async (db) => + await db.insert(paywallProducts).values(paywallProduct) + ) + ), - getPaywallLocationsUsingPaywall: dbService.makeQuery( - (execute, paywallId: string) => - execute( - async (db) => - await db.query.paywallLocations.findMany({ - where: eq(paywallLocations.defaultPaywallId, paywallId), - }) - ) - ), + deletePaywallProducts: dbService.makeQuery( + (execute, paywallId: string) => + execute( + async (db) => + await db + .delete(paywallProducts) + .where(eq(paywallProducts.paywallId, paywallId)) + ) + ), - getProductsWithConfigurations: dbService.makeQuery( - (execute, productIds: string[]) => - execute( - async (db) => - await db.query.products.findMany({ - where: inArray(products.id, productIds), - with: { - paymentProviderConfigurationProducts: true, - }, - }) - ) - ), - }; - }), + getPaywallLocationsUsingPaywall: dbService.makeQuery( + (execute, paywallId: string) => + execute( + async (db) => + await db.query.paywallLocations.findMany({ + where: eq(paywallLocations.defaultPaywallId, paywallId) + }) + ) + ), - // Specify dependencies - dependencies: [Db.Default], - } + getProductsWithConfigurations: dbService.makeQuery( + (execute, productIds: string[]) => + execute( + async (db) => + await db.query.products.findMany({ + where: inArray(products.id, productIds), + with: { + paymentProviderConfigurationProducts: true + } + }) + ) + ) + }; + }), + + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/perk.repository.ts b/apps/web/lib/repositories/perk.repository.ts index 70738f8d6..0c9296a54 100644 --- a/apps/web/lib/repositories/perk.repository.ts +++ b/apps/web/lib/repositories/perk.repository.ts @@ -1,79 +1,107 @@ -import { Db } from "@/lib/effect/db"; -import { and, eq, inArray, InsertPerk, paymentProviderConfigurationProducts, perks, productPerks, products } from "@voidhash/db"; -import { EnvironmentValue } from "@voidhash/lib/constants"; -import { Effect } from "effect"; +import { + and, + eq, + type InsertPerk, + inArray, + paymentProviderConfigurationProducts, + perks, + productPerks, + products +} from '@voidhash/db'; +import type { EnvironmentValue } from '@voidhash/lib/constants'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class PerkRepository extends Effect.Service()( - "PerkRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createPerk: dbService.makeQuery((execute, perk: InsertPerk) => - execute(async (db) => await db.insert(perks).values(perk)) - ), + 'PerkRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createPerk: dbService.makeQuery((execute, perk: InsertPerk) => + execute(async (db) => await db.insert(perks).values(perk)) + ), - getPerks: dbService.makeQuery( - ( - execute, - input: { projectId: string; environment: EnvironmentValue } - ) => - execute( - async (db) => - await db.query.perks.findMany({ - where: and( - eq(perks.projectId, input.projectId), - eq(perks.environment, input.environment) - ), - }) - ) - ), + getPerks: dbService.makeQuery( + ( + execute, + input: { projectId: string; environment: EnvironmentValue } + ) => + execute( + async (db) => + await db.query.perks.findMany({ + where: and( + eq(perks.projectId, input.projectId), + eq(perks.environment, input.environment) + ) + }) + ) + ), - getPerkById: dbService.makeQuery((execute, id: string) => - execute( - async (db) => - await db.query.perks.findFirst({ where: eq(perks.id, id) }) - ) - ), + getPerkById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.perks.findFirst({ where: eq(perks.id, id) }) + ) + ), - getPerkBySlug: dbService.makeQuery( - ( - execute, - input: { - slug: string; - projectId: string; - environment: EnvironmentValue; - } - ) => - execute( - async (db) => - await db.query.perks.findFirst({ - where: and( - eq(perks.slug, input.slug), - eq(perks.projectId, input.projectId), - eq(perks.environment, input.environment) - ), - }) - ) - ), + getPerkBySlug: dbService.makeQuery( + ( + execute, + input: { + slug: string; + projectId: string; + environment: EnvironmentValue; + } + ) => + execute( + async (db) => + await db.query.perks.findFirst({ + where: and( + eq(perks.slug, input.slug), + eq(perks.projectId, input.projectId), + eq(perks.environment, input.environment) + ) + }) + ) + ), - getPerksByPaymentProviderConfigurationProductIds: dbService.makeQuery((execute, paymentProviderConfigurationProductIds: string[]) => - execute(async (db) => - (await db.select().from(paymentProviderConfigurationProducts) - .innerJoin(products, eq(paymentProviderConfigurationProducts.productId, products.id)) - .innerJoin(productPerks, eq(products.id, productPerks.productId)) - .innerJoin(perks, eq(productPerks.perkId, perks.id)) - .where(inArray(paymentProviderConfigurationProducts.id, paymentProviderConfigurationProductIds))).map((row) => row.perk) - ) - ), + getPerksByPaymentProviderConfigurationProductIds: dbService.makeQuery( + (execute, paymentProviderConfigurationProductIds: string[]) => + execute(async (db) => + ( + await db + .select() + .from(paymentProviderConfigurationProducts) + .innerJoin( + products, + eq( + paymentProviderConfigurationProducts.productId, + products.id + ) + ) + .innerJoin( + productPerks, + eq(products.id, productPerks.productId) + ) + .innerJoin(perks, eq(productPerks.perkId, perks.id)) + .where( + inArray( + paymentProviderConfigurationProducts.id, + paymentProviderConfigurationProductIds + ) + ) + ).map((row) => row.perk) + ) + ), - deletePerk: dbService.makeQuery((execute, id: string) => - execute(async (db) => db.delete(perks).where(eq(perks.id, id))) - ), - }; - }), + deletePerk: dbService.makeQuery((execute, id: string) => + execute(async (db) => db.delete(perks).where(eq(perks.id, id))) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/product-perk.repository.ts b/apps/web/lib/repositories/product-perk.repository.ts index 96422c8a1..f788f6040 100644 --- a/apps/web/lib/repositories/product-perk.repository.ts +++ b/apps/web/lib/repositories/product-perk.repository.ts @@ -1,90 +1,90 @@ -import { Db } from "@/lib/effect/db"; import { - eq, - and, - asc, - InsertProductPerk, - productPerks, - paymentProviderConfigurationProducts, - products, - inArray, -} from "@voidhash/db"; -import { Effect } from "effect"; + and, + asc, + eq, + type InsertProductPerk, + inArray, + paymentProviderConfigurationProducts, + productPerks, + products +} from '@voidhash/db'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class ProductPerkRepository extends Effect.Service()( - "ProductPerkRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createProductPerk: dbService.makeQuery( - (execute, productPerk: InsertProductPerk) => - execute( - async (db) => await db.insert(productPerks).values(productPerk) - ) - ), + 'ProductPerkRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createProductPerk: dbService.makeQuery( + (execute, productPerk: InsertProductPerk) => + execute( + async (db) => await db.insert(productPerks).values(productPerk) + ) + ), - getProductPerksByProductId: dbService.makeQuery( - (execute, productId: string) => - execute( - async (db) => - await db.query.productPerks.findMany({ - where: eq(productPerks.productId, productId), - orderBy: [asc(productPerks.createdAt)], - }) - ) - ), + getProductPerksByProductId: dbService.makeQuery( + (execute, productId: string) => + execute( + async (db) => + await db.query.productPerks.findMany({ + where: eq(productPerks.productId, productId), + orderBy: [asc(productPerks.createdAt)] + }) + ) + ), - getProductPerksByPaymentProviderConfigurationProductIds: - dbService.makeQuery( - (execute, paymentProviderConfigurationProductIds: string[]) => - execute(async (db) => - ( - await db - .select() - .from(paymentProviderConfigurationProducts) - .innerJoin( - products, - eq( - paymentProviderConfigurationProducts.productId, - products.id - ) - ) - .innerJoin( - productPerks, - eq(productPerks.productId, products.id) - ) - .where( - inArray( - paymentProviderConfigurationProducts.id, - paymentProviderConfigurationProductIds - ) - ) - ).map((row) => row.product_perk) - ) - ), + getProductPerksByPaymentProviderConfigurationProductIds: + dbService.makeQuery( + (execute, paymentProviderConfigurationProductIds: string[]) => + execute(async (db) => + ( + await db + .select() + .from(paymentProviderConfigurationProducts) + .innerJoin( + products, + eq( + paymentProviderConfigurationProducts.productId, + products.id + ) + ) + .innerJoin( + productPerks, + eq(productPerks.productId, products.id) + ) + .where( + inArray( + paymentProviderConfigurationProducts.id, + paymentProviderConfigurationProductIds + ) + ) + ).map((row) => row.product_perk) + ) + ), - deleteProductPerk: dbService.makeQuery( - ( - execute, - { productId, perkId }: { productId: string; perkId: string } - ) => - execute( - async (db) => - await db - .delete(productPerks) - .where( - and( - eq(productPerks.productId, productId), - eq(productPerks.perkId, perkId) - ) - ) - ) - ), - }; - }), + deleteProductPerk: dbService.makeQuery( + ( + execute, + { productId, perkId }: { productId: string; perkId: string } + ) => + execute( + async (db) => + await db + .delete(productPerks) + .where( + and( + eq(productPerks.productId, productId), + eq(productPerks.perkId, perkId) + ) + ) + ) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/product.repository.ts b/apps/web/lib/repositories/product.repository.ts index 5569e7658..b5f8c6753 100644 --- a/apps/web/lib/repositories/product.repository.ts +++ b/apps/web/lib/repositories/product.repository.ts @@ -1,68 +1,63 @@ -import { Db } from "@/lib/effect/db"; -import { - eq, - and, - products, - InsertProduct, -} from "@voidhash/db"; -import { Effect } from "effect"; -import { EnvironmentValue } from "@voidhash/lib/constants"; +import { and, eq, type InsertProduct, products } from '@voidhash/db'; +import type { EnvironmentValue } from '@voidhash/lib/constants'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class ProductRepository extends Effect.Service()( - "ProductRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createProduct: dbService.makeQuery((execute, product: InsertProduct) => - execute(async (db) => await db.insert(products).values(product)) - ), + 'ProductRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createProduct: dbService.makeQuery((execute, product: InsertProduct) => + execute(async (db) => await db.insert(products).values(product)) + ), - getProducts: dbService.makeQuery( - ( - execute, - input: { projectId: string; environment: EnvironmentValue } - ) => - execute( - async (db) => - await db.query.products.findMany({ - where: and( - eq(products.projectId, input.projectId), - eq(products.environment, input.environment) - ), - }) - ) - ), + getProducts: dbService.makeQuery( + ( + execute, + input: { projectId: string; environment: EnvironmentValue } + ) => + execute( + async (db) => + await db.query.products.findMany({ + where: and( + eq(products.projectId, input.projectId), + eq(products.environment, input.environment) + ) + }) + ) + ), - getProductById: dbService.makeQuery((execute, id: string) => - execute( - async (db) => - await db.query.products.findFirst({ - where: eq(products.id, id), - }) - ) - ), + getProductById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.products.findFirst({ + where: eq(products.id, id) + }) + ) + ), - updateProduct: dbService.makeQuery( - (execute, { id, name }: { id: string; name: string }) => - execute( - async (db) => - await db - .update(products) - .set({ name, updatedAt: new Date() }) - .where(eq(products.id, id)) - ) - ), + updateProduct: dbService.makeQuery( + (execute, { id, name }: { id: string; name: string }) => + execute( + async (db) => + await db + .update(products) + .set({ name, updatedAt: new Date() }) + .where(eq(products.id, id)) + ) + ), - deleteProduct: dbService.makeQuery((execute, id: string) => - execute( - async (db) => await db.delete(products).where(eq(products.id, id)) - ) - ), - }; - }), + deleteProduct: dbService.makeQuery((execute, id: string) => + execute( + async (db) => await db.delete(products).where(eq(products.id, id)) + ) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/project.repository.ts b/apps/web/lib/repositories/project.repository.ts index e19853b3b..bc43e5046 100644 --- a/apps/web/lib/repositories/project.repository.ts +++ b/apps/web/lib/repositories/project.repository.ts @@ -1,74 +1,74 @@ -import { Db } from "@/lib/effect/db"; -import { and, eq, projects, InsertProject } from "@voidhash/db"; -import { Effect } from "effect"; +import { and, eq, type InsertProject, projects } from '@voidhash/db'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class ProjectRepository extends Effect.Service()( - "ProjectRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createProject: dbService.makeQuery((execute, project: InsertProject) => - execute(async (db) => await db.insert(projects).values(project)) - ), + 'ProjectRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createProject: dbService.makeQuery((execute, project: InsertProject) => + execute(async (db) => await db.insert(projects).values(project)) + ), - getProjectBySlug: dbService.makeQuery( - ( - execute, - { - projectSlug, - organizationId, - }: { projectSlug: string; organizationId: string } - ) => - execute( - async (db) => - await db.query.projects.findFirst({ - where: and( - eq(projects.slug, projectSlug), - eq(projects.organizationId, organizationId) - ), - }) - ) - ), + getProjectBySlug: dbService.makeQuery( + ( + execute, + { + projectSlug, + organizationId + }: { projectSlug: string; organizationId: string } + ) => + execute( + async (db) => + await db.query.projects.findFirst({ + where: and( + eq(projects.slug, projectSlug), + eq(projects.organizationId, organizationId) + ) + }) + ) + ), - getProjectById: dbService.makeQuery((execute, id: string) => - execute( - async (db) => - await db.query.projects.findFirst({ - where: eq(projects.id, id), - }) - ) - ), + getProjectById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.projects.findFirst({ + where: eq(projects.id, id) + }) + ) + ), - getProjects: dbService.makeQuery((execute, organizationId: string) => - execute( - async (db) => - await db.query.projects.findMany({ - where: eq(projects.organizationId, organizationId), - }) - ) - ), + getProjects: dbService.makeQuery((execute, organizationId: string) => + execute( + async (db) => + await db.query.projects.findMany({ + where: eq(projects.organizationId, organizationId) + }) + ) + ), - updateProject: dbService.makeQuery( - (execute, { id, name }: { id: string; name: string }) => - execute( - async (db) => - await db - .update(projects) - .set({ name }) - .where(eq(projects.id, id)) - ) - ), + updateProject: dbService.makeQuery( + (execute, { id, name }: { id: string; name: string }) => + execute( + async (db) => + await db + .update(projects) + .set({ name }) + .where(eq(projects.id, id)) + ) + ), - deleteProject: dbService.makeQuery((execute, id: string) => - execute( - async (db) => await db.delete(projects).where(eq(projects.id, id)) - ) - ), - }; - }), + deleteProject: dbService.makeQuery((execute, id: string) => + execute( + async (db) => await db.delete(projects).where(eq(projects.id, id)) + ) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/subscription.repository.ts b/apps/web/lib/repositories/subscription.repository.ts index bb52ec7dd..c432fd5db 100644 --- a/apps/web/lib/repositories/subscription.repository.ts +++ b/apps/web/lib/repositories/subscription.repository.ts @@ -1,97 +1,121 @@ -import { Db } from "@/lib/effect/db"; import { - and, - customers, - eq, - InsertSubscription, - Subscription, - subscriptions, -} from "@voidhash/db"; -import { Effect } from "effect"; + and, + customers, + eq, + type InsertSubscription, + type Subscription, + subscriptions +} from '@voidhash/db'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class SubscriptionRepository extends Effect.Service()( - "SubscriptionRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createSubscription: dbService.makeQuery( - (execute, input: InsertSubscription) => - execute(async (db) => await db.insert(subscriptions).values(input)) - ), - getSubscriptionById: dbService.makeQuery( - (execute, id: string) => - execute(async (db) => await db.query.subscriptions.findFirst({ where: eq(subscriptions.id, id) })) - ), - getSubscriptionByStoreSubscriptionId: dbService.makeQuery( - ( - execute, - input: { - storeSubscriptionId: string; - projectId: string; - } - ) => - execute( - async (db) => - await db - .select() - .from(subscriptions) - .innerJoin( - customers, - eq(subscriptions.customerId, customers.id) - ) - .where( - and( - eq( - subscriptions.storeSubscriptionId, - input.storeSubscriptionId - ), - eq(customers.projectId, input.projectId) - ) - ) - ) - ), - getSubscriptionByInitialTransactionId: dbService.makeQuery( - ( - execute, - input: { initialTransactionId: string; projectId: string } - ) => - execute( - async (db) => - await db - .select() - .from(subscriptions) - .where( - and( - eq( - subscriptions.initialTransactionId, - input.initialTransactionId - ), - eq(subscriptions.customerId, input.projectId) - ) - ) - ) - ), - getSubscriptionsByCustomerId: dbService.makeQuery( - (execute, customerId: string) => - execute(async (db) => await db.query.subscriptions.findMany({ where: eq(subscriptions.customerId, customerId) })) - ), - - getSubscriptionsByCustomerIdWithPaymentProviderConfigurationProduct: dbService.makeQuery( - (execute, customerId: string) => - execute(async (db) => await db.query.subscriptions.findMany({ where: eq(subscriptions.customerId, customerId), with: { - paymentProviderConfigurationProduct: true - } })) - ), + 'SubscriptionRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createSubscription: dbService.makeQuery( + (execute, input: InsertSubscription) => + execute(async (db) => await db.insert(subscriptions).values(input)) + ), + getSubscriptionById: dbService.makeQuery((execute, id: string) => + execute( + async (db) => + await db.query.subscriptions.findFirst({ + where: eq(subscriptions.id, id) + }) + ) + ), + getSubscriptionByStoreSubscriptionId: dbService.makeQuery( + ( + execute, + input: { + storeSubscriptionId: string; + projectId: string; + } + ) => + execute( + async (db) => + await db + .select() + .from(subscriptions) + .innerJoin( + customers, + eq(subscriptions.customerId, customers.id) + ) + .where( + and( + eq( + subscriptions.storeSubscriptionId, + input.storeSubscriptionId + ), + eq(customers.projectId, input.projectId) + ) + ) + ) + ), + getSubscriptionByInitialTransactionId: dbService.makeQuery( + ( + execute, + input: { initialTransactionId: string; projectId: string } + ) => + execute( + async (db) => + await db + .select() + .from(subscriptions) + .where( + and( + eq( + subscriptions.initialTransactionId, + input.initialTransactionId + ), + eq(subscriptions.customerId, input.projectId) + ) + ) + ) + ), + getSubscriptionsByCustomerId: dbService.makeQuery( + (execute, customerId: string) => + execute( + async (db) => + await db.query.subscriptions.findMany({ + where: eq(subscriptions.customerId, customerId) + }) + ) + ), - updateSubscription: dbService.makeQuery( - (execute, input: Omit, "id"> & { id: string }) => - execute(async (db) => await db.update(subscriptions).set(input).where(eq(subscriptions.id, input.id))) - ), - }; - }), + getSubscriptionsByCustomerIdWithPaymentProviderConfigurationProduct: + dbService.makeQuery((execute, customerId: string) => + execute( + async (db) => + await db.query.subscriptions.findMany({ + where: eq(subscriptions.customerId, customerId), + with: { + paymentProviderConfigurationProduct: true + } + }) + ) + ), - // Specify dependencies - dependencies: [Db.Default], - } + updateSubscription: dbService.makeQuery( + ( + execute, + input: Omit, 'id'> & { id: string } + ) => + execute( + async (db) => + await db + .update(subscriptions) + .set(input) + .where(eq(subscriptions.id, input.id)) + ) + ) + }; + }), + + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/repositories/transaction.repository.ts b/apps/web/lib/repositories/transaction.repository.ts index 1322e2fbe..746afbe84 100644 --- a/apps/web/lib/repositories/transaction.repository.ts +++ b/apps/web/lib/repositories/transaction.repository.ts @@ -1,21 +1,21 @@ -import { Db } from "@/lib/effect/db"; -import { InsertTransaction, transactions } from "@voidhash/db"; -import { Effect } from "effect"; +import { type InsertTransaction, transactions } from '@voidhash/db'; +import { Effect } from 'effect'; +import { Db } from '@/lib/effect/db'; export class TransactionRepository extends Effect.Service()( - "TransactionRepository", - { - effect: Effect.gen(function* () { - const dbService = yield* Db; - return { - createTransaction: dbService.makeQuery( - (execute, input: InsertTransaction) => - execute(async (db) => await db.insert(transactions).values(input)) - ), - }; - }), + 'TransactionRepository', + { + effect: Effect.gen(function* () { + const dbService = yield* Db; + return { + createTransaction: dbService.makeQuery( + (execute, input: InsertTransaction) => + execute(async (db) => await db.insert(transactions).values(input)) + ) + }; + }), - // Specify dependencies - dependencies: [Db.Default], - } + // Specify dependencies + dependencies: [Db.Default] + } ) {} diff --git a/apps/web/lib/safe-action.ts b/apps/web/lib/safe-action.ts index 658824c5b..fa7041d25 100644 --- a/apps/web/lib/safe-action.ts +++ b/apps/web/lib/safe-action.ts @@ -1,5 +1,3 @@ -import { - createSafeActionClient, -} from "next-safe-action"; +import { createSafeActionClient } from 'next-safe-action'; -export const actionClient = createSafeActionClient() \ No newline at end of file +export const actionClient = createSafeActionClient(); diff --git a/apps/web/lib/services/api-key.service.ts b/apps/web/lib/services/api-key.service.ts index 69725e868..0254d898a 100644 --- a/apps/web/lib/services/api-key.service.ts +++ b/apps/web/lib/services/api-key.service.ts @@ -1,170 +1,170 @@ -import { Data, Effect } from "effect"; -import { ApiKeyRepository } from "../repositories/api-key.repository"; -import { AuthSession } from "@/lib/services/auth.service"; -import { Environment } from "@/lib/services/environment.service"; -import { checkProjectPermission } from "@/lib/effect/permissions"; -import { createSecretKey as generateSecretKeyFn } from "../core/api-keys/effect/utils"; -import { generateId } from "@/lib/id/generate"; +import { Data, Effect } from 'effect'; +import { checkProjectPermission } from '@/lib/effect/permissions'; +import { generateId } from '@/lib/id/generate'; +import { AuthSession } from '@/lib/services/auth.service'; +import { Environment } from '@/lib/services/environment.service'; +import { createSecretKey as generateSecretKeyFn } from '../core/api-keys/effect/utils'; +import { ApiKeyRepository } from '../repositories/api-key.repository'; export class ApiKeyNotFoundError extends Data.TaggedError( - "ApiKeyNotFoundError", + 'ApiKeyNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class ApiKeyService extends Effect.Service()( - "ApiKeyService", - { - dependencies: [ApiKeyRepository.Default], - effect: Effect.gen(function* () { - const apiKeyRepository = yield* ApiKeyRepository; - return { - createSecretKey: (input: { projectId: string; name: string }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - const apiKeyRepository = yield* ApiKeyRepository; - - // SECURITY: Authorization check - yield* checkProjectPermission( - input.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to create secret keys for project ${input.projectId}`, - ); - - const { rawKey, ...secretKey } = - yield* generateSecretKeyFn(environment); - const apiKeyId = generateId("apiSecretKey"); - yield* apiKeyRepository.createApiKey({ - id: apiKeyId, - projectId: input.projectId, - name: input.name, - ...secretKey, - }); - - const apiKey = yield* apiKeyRepository.getApiKeyById(apiKeyId); - if (!apiKey) { - return yield* Effect.fail( - new ApiKeyNotFoundError({ - message: "API key not found", - }), - ); - } - - return { - ...apiKey, - rawKey, - }; - }), - // Environment.withEnvironment({ - // projectId: input.projectId, - // }), - // AuthSession.withAuthSession() - - getApiKeys: (projectId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - // SECURITY: Authorization check - yield* checkProjectPermission( - projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access api keys for project ${projectId}`, - ); - const apiKeys = yield* apiKeyRepository.getApiKeys(projectId); - return apiKeys.filter((key) => key.environment === environment); - }), - - getApiKeyById: (id: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - - const apiKey = yield* apiKeyRepository.getApiKeyById(id); - if (!apiKey) { - return yield* Effect.fail( - new ApiKeyNotFoundError({ - message: "API key not found", - }), - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - apiKey.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access api key ${id} for project ${apiKey.projectId}`, - ); - - return apiKey; - }), - - deleteSecretKey: (input: { secretKeyId: string }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const apiKeyRepository = yield* ApiKeyRepository; - - const existingKey = yield* apiKeyRepository.getApiKeyById( - input.secretKeyId, - ); - if (!existingKey) { - return yield* Effect.fail( - new ApiKeyNotFoundError({ - message: "Secret key not found", - }), - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - existingKey.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to delete secret key ${input.secretKeyId} for project ${existingKey.projectId}`, - ); - - yield* apiKeyRepository.deleteApiKey(input.secretKeyId); - }), - - rotateSecretKey: (input: { secretKeyId: string }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const apiKeyRepository = yield* ApiKeyRepository; - - const existingKey = yield* apiKeyRepository.getApiKeyById( - input.secretKeyId, - ); - if (!existingKey) { - return yield* Effect.fail( - new ApiKeyNotFoundError({ - message: "Secret key not found", - }), - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - existingKey.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to rotate secret key ${input.secretKeyId} for project ${existingKey.projectId}`, - ); - - const { rawKey, ...newKey } = yield* generateSecretKeyFn( - existingKey.environment, - ); - yield* apiKeyRepository.updateApiKey({ - id: input.secretKeyId, - ...newKey, - updatedAt: new Date(), - createdAt: new Date(), - }); - - return { - ...existingKey, - ...newKey, - rawKey, - }; - }), - }; - }), - }, + 'ApiKeyService', + { + dependencies: [ApiKeyRepository.Default], + effect: Effect.gen(function* () { + const apiKeyRepository = yield* ApiKeyRepository; + return { + createSecretKey: (input: { projectId: string; name: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + const apiKeyRepository = yield* ApiKeyRepository; + + // SECURITY: Authorization check + yield* checkProjectPermission( + input.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to create secret keys for project ${input.projectId}` + ); + + const { rawKey, ...secretKey } = + yield* generateSecretKeyFn(environment); + const apiKeyId = generateId('apiSecretKey'); + yield* apiKeyRepository.createApiKey({ + id: apiKeyId, + projectId: input.projectId, + name: input.name, + ...secretKey + }); + + const apiKey = yield* apiKeyRepository.getApiKeyById(apiKeyId); + if (!apiKey) { + return yield* Effect.fail( + new ApiKeyNotFoundError({ + message: 'API key not found' + }) + ); + } + + return { + ...apiKey, + rawKey + }; + }), + // Environment.withEnvironment({ + // projectId: input.projectId, + // }), + // AuthSession.withAuthSession() + + getApiKeys: (projectId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + // SECURITY: Authorization check + yield* checkProjectPermission( + projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access api keys for project ${projectId}` + ); + const apiKeys = yield* apiKeyRepository.getApiKeys(projectId); + return apiKeys.filter((key) => key.environment === environment); + }), + + getApiKeyById: (id: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + + const apiKey = yield* apiKeyRepository.getApiKeyById(id); + if (!apiKey) { + return yield* Effect.fail( + new ApiKeyNotFoundError({ + message: 'API key not found' + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + apiKey.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access api key ${id} for project ${apiKey.projectId}` + ); + + return apiKey; + }), + + deleteSecretKey: (input: { secretKeyId: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const apiKeyRepository = yield* ApiKeyRepository; + + const existingKey = yield* apiKeyRepository.getApiKeyById( + input.secretKeyId + ); + if (!existingKey) { + return yield* Effect.fail( + new ApiKeyNotFoundError({ + message: 'Secret key not found' + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + existingKey.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to delete secret key ${input.secretKeyId} for project ${existingKey.projectId}` + ); + + yield* apiKeyRepository.deleteApiKey(input.secretKeyId); + }), + + rotateSecretKey: (input: { secretKeyId: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const apiKeyRepository = yield* ApiKeyRepository; + + const existingKey = yield* apiKeyRepository.getApiKeyById( + input.secretKeyId + ); + if (!existingKey) { + return yield* Effect.fail( + new ApiKeyNotFoundError({ + message: 'Secret key not found' + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + existingKey.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to rotate secret key ${input.secretKeyId} for project ${existingKey.projectId}` + ); + + const { rawKey, ...newKey } = yield* generateSecretKeyFn( + existingKey.environment + ); + yield* apiKeyRepository.updateApiKey({ + id: input.secretKeyId, + ...newKey, + updatedAt: new Date(), + createdAt: new Date() + }); + + return { + ...existingKey, + ...newKey, + rawKey + }; + }) + }; + }) + } ) {} diff --git a/apps/web/lib/services/auth.service.ts b/apps/web/lib/services/auth.service.ts index a64a74bd0..0449787ba 100644 --- a/apps/web/lib/services/auth.service.ts +++ b/apps/web/lib/services/auth.service.ts @@ -1,346 +1,346 @@ -import { Context, Data, Effect, Option } from "effect"; -import { hashKey } from "@/lib/core/api-keys/effect/utils"; -import { apiKeys, projects, User } from "@voidhash/db"; -import { EnvironmentValue } from "@voidhash/lib/constants"; -import { eq, inArray } from "drizzle-orm"; -import { Db } from "../effect/db"; -import { Request } from "../effect/request"; -import { BetterAuth } from "../effect/better-auth"; -import { UnauthorizedError } from "../effect/errors"; -import { NextjsRuntimeTag, HonoRuntimeTag } from "../effect/runtimes/tags"; - -export class InvalidSourceError extends Data.TaggedError("InvalidSourceError")<{ - readonly cause?: unknown; - readonly message: string; +import { apiKeys, projects, type User } from '@voidhash/db'; +import type { EnvironmentValue } from '@voidhash/lib/constants'; +import { eq, inArray } from 'drizzle-orm'; +import { Context, Data, Effect, Option } from 'effect'; +import { hashKey } from '@/lib/core/api-keys/effect/utils'; +import { BetterAuth } from '../effect/better-auth'; +import { Db } from '../effect/db'; +import { UnauthorizedError } from '../effect/errors'; +import { Request } from '../effect/request'; +import { HonoRuntimeTag, NextjsRuntimeTag } from '../effect/runtimes/tags'; + +export class InvalidSourceError extends Data.TaggedError('InvalidSourceError')<{ + readonly cause?: unknown; + readonly message: string; }> {} export class MissingSecretKeyError extends Data.TaggedError( - "MissingSecretKeyError" + 'MissingSecretKeyError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class InvalidSecretKeyError extends Data.TaggedError( - "InvalidSecretKeyError" + 'InvalidSecretKeyError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class MissingPublishableKeyError extends Data.TaggedError( - "MissingPublishableKeyError" + 'MissingPublishableKeyError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class MissingAppUserIdError extends Data.TaggedError( - "MissingAppUserIdError" + 'MissingAppUserIdError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class InvalidPublishableKeyError extends Data.TaggedError( - "InvalidPublishableKeyError" + 'InvalidPublishableKeyError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class MissingProjectIdError extends Data.TaggedError( - "MissingProjectIdError" + 'MissingProjectIdError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} type VoidhashBaseSession = { - readonly organizations: { - readonly id: string; - readonly slug: string; - readonly permissions: string[]; - }[]; - readonly projects: { - readonly id: string; - readonly slug: string; - readonly organizationId: string; - readonly permissions: string[]; - }[]; + readonly organizations: { + readonly id: string; + readonly slug: string; + readonly permissions: string[]; + }[]; + readonly projects: { + readonly id: string; + readonly slug: string; + readonly organizationId: string; + readonly permissions: string[]; + }[]; }; export type UserSession = VoidhashBaseSession & { - readonly method: "user"; - readonly user: User; - readonly customer: null; - readonly environment: null; + readonly method: 'user'; + readonly user: User; + readonly customer: null; + readonly environment: null; }; export type ApiKeySession = VoidhashBaseSession & { - readonly method: "api-key"; - readonly user: null; - readonly customer: null; - readonly environment: EnvironmentValue; + readonly method: 'api-key'; + readonly user: null; + readonly customer: null; + readonly environment: EnvironmentValue; }; export type PublishableApiKeySession = VoidhashBaseSession & { - readonly method: "publishable-api-key"; - readonly customer: { - readonly appUserId: string; - readonly sdkOrigin: string | null; - readonly sdkVersion: string | null; - readonly os: string | null; - readonly device: string | null; - }; - readonly user: null; - readonly environment: EnvironmentValue; + readonly method: 'publishable-api-key'; + readonly customer: { + readonly appUserId: string; + readonly sdkOrigin: string | null; + readonly sdkVersion: string | null; + readonly os: string | null; + readonly device: string | null; + }; + readonly user: null; + readonly environment: EnvironmentValue; }; -export class AuthSession extends Context.Tag("app/AuthSession")< - AuthSession, - UserSession | ApiKeySession | PublishableApiKeySession +export class AuthSession extends Context.Tag('app/AuthSession')< + AuthSession, + UserSession | ApiKeySession | PublishableApiKeySession >() { - public static readonly provide = ( - session: UserSession | ApiKeySession | PublishableApiKeySession - ): (( - self: Effect.Effect - ) => Effect.Effect>) => - Effect.provideService(this, session); + static readonly provide = ( + session: UserSession | ApiKeySession | PublishableApiKeySession + ): (( + self: Effect.Effect + ) => Effect.Effect>) => + Effect.provideService(this, session); } export class AuthService extends Effect.Service()( - "app/AuthService", - { - dependencies: [Db.Default], - - effect: Effect.gen(function* () { - return { - authenticateWithSession: () => - Effect.gen(function* () { - // Works only in Next.js runtime - yield* NextjsRuntimeTag; - const existingSession = yield* Effect.serviceOption(AuthSession); - if (Option.isSome(existingSession)) { - return existingSession.value; - } - const userAuthSession = yield* getUserAuthSession; - return userAuthSession; - }), - - authenticateWithSecretKey: () => - Effect.gen(function* () { - // Works only in Next.js runtime - yield* HonoRuntimeTag; - const existingSession = yield* Effect.serviceOption(AuthSession); - if (Option.isSome(existingSession)) { - return existingSession.value; - } - const secretApiKeyAuthSession = yield* getSecretApiKeyAuthSession; - return secretApiKeyAuthSession; - }), - - authenticateWithPublishableKey: () => - Effect.gen(function* () { - // Works only in Next.js runtime - yield* HonoRuntimeTag; - const existingSession = yield* Effect.serviceOption(AuthSession); - if (Option.isSome(existingSession)) { - return existingSession.value; - } - const publishableApiKeyAuthSession = - yield* getPublishableApiKeyAuthSession; - return publishableApiKeyAuthSession; - }), - - getAuthorizedProjectId: () => - Effect.gen(function* () { - const authSession = yield* AuthSession; - const projectId = authSession.projects[0]?.id; - if (!projectId) { - return yield* Effect.fail( - new MissingProjectIdError({ - message: "No project id found in session", - }) - ); - } - return projectId; - }), - }; - }), - } + 'app/AuthService', + { + dependencies: [Db.Default], + + effect: Effect.gen(function* () { + return { + authenticateWithSession: () => + Effect.gen(function* () { + // Works only in Next.js runtime + yield* NextjsRuntimeTag; + const existingSession = yield* Effect.serviceOption(AuthSession); + if (Option.isSome(existingSession)) { + return existingSession.value; + } + const userAuthSession = yield* getUserAuthSession; + return userAuthSession; + }), + + authenticateWithSecretKey: () => + Effect.gen(function* () { + // Works only in Next.js runtime + yield* HonoRuntimeTag; + const existingSession = yield* Effect.serviceOption(AuthSession); + if (Option.isSome(existingSession)) { + return existingSession.value; + } + const secretApiKeyAuthSession = yield* getSecretApiKeyAuthSession; + return secretApiKeyAuthSession; + }), + + authenticateWithPublishableKey: () => + Effect.gen(function* () { + // Works only in Next.js runtime + yield* HonoRuntimeTag; + const existingSession = yield* Effect.serviceOption(AuthSession); + if (Option.isSome(existingSession)) { + return existingSession.value; + } + const publishableApiKeyAuthSession = + yield* getPublishableApiKeyAuthSession; + return publishableApiKeyAuthSession; + }), + + getAuthorizedProjectId: () => + Effect.gen(function* () { + const authSession = yield* AuthSession; + const projectId = authSession.projects[0]?.id; + if (!projectId) { + return yield* Effect.fail( + new MissingProjectIdError({ + message: 'No project id found in session' + }) + ); + } + return projectId; + }) + }; + }) + } ) {} const getUserAuthSession = Effect.gen(function* () { - const betterAuth = yield* BetterAuth; - const request = yield* Request; - const headers = yield* request.getHeaders; - const session = yield* betterAuth.use(async (client) => { - return await client.api.getSession({ - headers, - }); - }); - - if (!session?.user) { - return yield* Effect.fail( - new UnauthorizedError({ - message: "You are not authenticated", - }) - ); - } - - const usersOrganizations = yield* betterAuth.use(async (client) => { - return await client.api.listOrganizations({ - headers, - }); - }); - - const dbService = yield* Db; - const usersProjects = yield* dbService.use(async (db) => { - return await db.query.projects.findMany({ - where: inArray( - projects.organizationId, - usersOrganizations.map((o) => o.id) - ), - }); - }); - - return { - method: "user", - user: { - ...session.user, - image: session.user.image ?? null, - }, - customer: null, - organizations: usersOrganizations.map((o) => ({ - id: o.id, - slug: o.slug, - permissions: ["organization:all"], // TODO: Add permissions - })), - environment: null, - projects: usersProjects.map((p) => ({ - id: p.id, - slug: p.slug, - organizationId: p.organizationId, - permissions: ["project:all"], // TODO: Add permissions - })), - } satisfies UserSession; + const betterAuth = yield* BetterAuth; + const request = yield* Request; + const headers = yield* request.getHeaders(); + const session = yield* betterAuth.use(async (client) => { + return await client.api.getSession({ + headers + }); + }); + + if (!session?.user) { + return yield* Effect.fail( + new UnauthorizedError({ + message: 'You are not authenticated' + }) + ); + } + + const usersOrganizations = yield* betterAuth.use(async (client) => { + return await client.api.listOrganizations({ + headers + }); + }); + + const dbService = yield* Db; + const usersProjects = yield* dbService.use(async (db) => { + return await db.query.projects.findMany({ + where: inArray( + projects.organizationId, + usersOrganizations.map((o) => o.id) + ) + }); + }); + + return { + method: 'user', + user: { + ...session.user, + image: session.user.image ?? null + }, + customer: null, + organizations: usersOrganizations.map((o) => ({ + id: o.id, + slug: o.slug, + permissions: ['organization:all'] // TODO: Add permissions + })), + environment: null, + projects: usersProjects.map((p) => ({ + id: p.id, + slug: p.slug, + organizationId: p.organizationId, + permissions: ['project:all'] // TODO: Add permissions + })) + } satisfies UserSession; }); const getSecretApiKeyAuthSession = Effect.gen(function* () { - const request = yield* Request; - - const headers = yield* request.getHeaders; - const apiKey = headers.get("x-secret-key"); - - if (!apiKey) { - return yield* Effect.fail( - new MissingSecretKeyError({ - message: "No Secret Key provided.", - }) - ); - } - - const keyHash = yield* hashKey(apiKey); - const dbService = yield* Db; - const apiKeyRecord = yield* dbService.use(async (db) => { - return await db.query.apiKeys.findFirst({ - where: eq(apiKeys.key, keyHash), - with: { - project: true, - }, - }); - }); - - if (!apiKeyRecord) { - return yield* Effect.fail( - new InvalidSecretKeyError({ - message: "Invalid Secret Key.", - }) - ); - } - - const projects = [apiKeyRecord.project]; - - return { - method: "api-key", - customer: null, - user: null, - environment: apiKeyRecord.environment, - organizations: [], - projects: projects.map((p) => ({ - id: p.id, - slug: p.slug, - organizationId: p.organizationId, - permissions: ["project:all"], // TODO: Add permissions - })), - } satisfies ApiKeySession; + const request = yield* Request; + + const headers = yield* request.getHeaders(); + const apiKey = headers.get('x-secret-key'); + + if (!apiKey) { + return yield* Effect.fail( + new MissingSecretKeyError({ + message: 'No Secret Key provided.' + }) + ); + } + + const keyHash = yield* hashKey(apiKey); + const dbService = yield* Db; + const apiKeyRecord = yield* dbService.use(async (db) => { + return await db.query.apiKeys.findFirst({ + where: eq(apiKeys.key, keyHash), + with: { + project: true + } + }); + }); + + if (!apiKeyRecord) { + return yield* Effect.fail( + new InvalidSecretKeyError({ + message: 'Invalid Secret Key.' + }) + ); + } + + const projects = [apiKeyRecord.project]; + + return { + method: 'api-key', + customer: null, + user: null, + environment: apiKeyRecord.environment, + organizations: [], + projects: projects.map((p) => ({ + id: p.id, + slug: p.slug, + organizationId: p.organizationId, + permissions: ['project:all'] // TODO: Add permissions + })) + } satisfies ApiKeySession; }); export const getPublishableApiKeyAuthSession = Effect.gen(function* () { - const request = yield* Request; - const headers = yield* request.getHeaders; - - const publishableApiKey = headers.get("x-publishable-key"); - if (!publishableApiKey) { - return yield* Effect.fail( - new MissingPublishableKeyError({ - message: - "Publishable key is required. Add it to the x-publishable-key header.", - }) - ); - } - - const dbService = yield* Db; - const apiKeyRecord = yield* dbService.use(async (db) => { - return await db.query.apiKeys.findFirst({ - where: eq(apiKeys.key, publishableApiKey), - with: { - project: true, - }, - }); - }); - if (!apiKeyRecord) { - return yield* Effect.fail( - new InvalidPublishableKeyError({ - message: "Invalid Publishable Key.", - }) - ); - } - - const appUserId = headers.get("x-app-user-id"); - if (!appUserId) { - return yield* Effect.fail( - new MissingAppUserIdError({ - message: "App User ID not found.", - }) - ); - } - - const sdkOrigin = headers.get("x-sdk-origin"); - const sdkVersion = headers.get("x-sdk-version"); - const os = headers.get("x-os"); - const device = headers.get("x-device"); - - const projects = [apiKeyRecord.project]; - - return { - method: "publishable-api-key", - user: null, - customer: { - appUserId: appUserId, - sdkOrigin, - sdkVersion, - os, - device, - }, - environment: apiKeyRecord.environment, - organizations: [] as never[], - projects: projects.map((p) => ({ - id: p.id, - slug: p.slug, - organizationId: p.organizationId, - permissions: [], - })), - } satisfies PublishableApiKeySession; + const request = yield* Request; + const headers = yield* request.getHeaders(); + + const publishableApiKey = headers.get('x-publishable-key'); + if (!publishableApiKey) { + return yield* Effect.fail( + new MissingPublishableKeyError({ + message: + 'Publishable key is required. Add it to the x-publishable-key header.' + }) + ); + } + + const dbService = yield* Db; + const apiKeyRecord = yield* dbService.use(async (db) => { + return await db.query.apiKeys.findFirst({ + where: eq(apiKeys.key, publishableApiKey), + with: { + project: true + } + }); + }); + if (!apiKeyRecord) { + return yield* Effect.fail( + new InvalidPublishableKeyError({ + message: 'Invalid Publishable Key.' + }) + ); + } + + const appUserId = headers.get('x-app-user-id'); + if (!appUserId) { + return yield* Effect.fail( + new MissingAppUserIdError({ + message: 'App User ID not found.' + }) + ); + } + + const sdkOrigin = headers.get('x-sdk-origin'); + const sdkVersion = headers.get('x-sdk-version'); + const os = headers.get('x-os'); + const device = headers.get('x-device'); + + const projects = [apiKeyRecord.project]; + + return { + method: 'publishable-api-key', + user: null, + customer: { + appUserId, + sdkOrigin, + sdkVersion, + os, + device + }, + environment: apiKeyRecord.environment, + organizations: [] as never[], + projects: projects.map((p) => ({ + id: p.id, + slug: p.slug, + organizationId: p.organizationId, + permissions: [] + })) + } satisfies PublishableApiKeySession; }); diff --git a/apps/web/lib/services/customer.service.ts b/apps/web/lib/services/customer.service.ts index 6633a7a58..2abfafeec 100644 --- a/apps/web/lib/services/customer.service.ts +++ b/apps/web/lib/services/customer.service.ts @@ -1,255 +1,260 @@ -import { Data, Effect } from "effect"; -import { CustomerRepository } from "../repositories/customer.repository"; -import { AuthSession } from "@/lib/services/auth.service"; -import { Environment } from "@/lib/services/environment.service"; -import { checkProjectPermission } from "@/lib/effect/permissions"; import { - CustomerOriginValue, - CustomerType, - CustomerTypeValue, - InsertCustomer, -} from "@voidhash/db"; -import { generateId } from "@/lib/id/generate"; -import { EnvironmentValue } from "@voidhash/lib/index"; -import { ANONYMOUS_USER_ID_PREFIX } from "../core/sdk/constants"; + type CustomerOriginValue, + CustomerType, + type CustomerTypeValue, + type InsertCustomer +} from '@voidhash/db'; +import type { EnvironmentValue } from '@voidhash/lib/index'; +import { Data, Effect } from 'effect'; +import { checkProjectPermission } from '@/lib/effect/permissions'; +import { generateId } from '@/lib/id/generate'; +import { AuthSession } from '@/lib/services/auth.service'; +import { Environment } from '@/lib/services/environment.service'; +import { ANONYMOUS_USER_ID_PREFIX } from '../core/sdk/constants'; +import { CustomerRepository } from '../repositories/customer.repository'; export class CustomerNotFoundError extends Data.TaggedError( - "CustomerNotFoundError", + 'CustomerNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class InvalidAnonymousIdError extends Data.TaggedError( - "InvalidAnonymousIdError", + 'InvalidAnonymousIdError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class CustomerService extends Effect.Service()( - "CustomerService", - { - dependencies: [CustomerRepository.Default], - effect: Effect.gen(function* () { - const customerRepository = yield* CustomerRepository; - return { - createCustomer: (input: { - projectId: string; - appUserId: string; - name?: string | null; - email?: string | null; - origin: CustomerOriginValue; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - const customerRepository = yield* CustomerRepository; - - // SECURITY: Authorization check - yield* checkProjectPermission( - input.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to create customers for project ${input.projectId}`, - ); - - const newCustomer = { - id: generateId("customer"), - projectId: input.projectId, - appUserId: input.appUserId, - type: CustomerType.Identified, - name: input.name ?? null, - email: input.email ?? null, - parentCustomerId: null, - origin: input.origin, - environment: environment, - } satisfies InsertCustomer; - - yield* customerRepository.createCustomer(newCustomer); - return { - ...newCustomer, - createdAt: new Date(), - updatedAt: new Date(), - }; - }), - - createAnonymousCustomer: (input: { - projectId: string; - appUserId: string; - origin: CustomerOriginValue; - environment: EnvironmentValue; - }) => - Effect.gen(function* () { - const customerRepository = yield* CustomerRepository; - - if (!input.appUserId.startsWith(ANONYMOUS_USER_ID_PREFIX)) { - return yield* Effect.fail( - new InvalidAnonymousIdError({ - message: `Invalid anonymous id: ${input.appUserId}`, - }), - ); - } - - const newCustomer = { - id: generateId("customer"), - type: CustomerType.Anonymous, - parentCustomerId: null, - projectId: input.projectId, - appUserId: input.appUserId, - origin: input.origin, - environment: input.environment, - name: null, - email: null, - } satisfies InsertCustomer; - - yield* customerRepository.createCustomer(newCustomer); - - yield* Effect.log( - `Created anonymous customer ${newCustomer.id} for app user ${input.appUserId}`, - ); - - return yield* Effect.succeed({ - ...newCustomer, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }); - }), - - getCustomers: ({ - projectId, - type, - }: { - projectId: string; - type?: CustomerTypeValue; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - yield* checkProjectPermission( - projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access customers for project ${projectId}`, - ); - return yield* customerRepository.getCustomers({ - projectId, - environment, - type: type ?? null, - }); - }), - getCustomerById: (id: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const customer = yield* customerRepository.getCustomerById(id); - if (!customer) { - return yield* Effect.fail( - new CustomerNotFoundError({ - message: "Customer not found", - }), - ); - } - yield* checkProjectPermission( - customer.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access customer ${id} for project ${customer.projectId}`, - ); - return customer; - }), - - getCustomerByAppUserId: (appUserId: string) => - Effect.gen(function* () { - const environment = yield* Environment; - const session = yield* AuthSession; - const projectId = session?.projects[0]?.id; - if (!projectId) { - return yield* Effect.dieMessage( - "Project ID not found after authentication", - ); - } - const customer = yield* customerRepository.getCustomerByAppUserId({ - projectId, - appUserId, - environment, - }); - if (!customer) { - return yield* Effect.fail( - new CustomerNotFoundError({ - message: "Customer not found", - }), - ); - } - yield* checkProjectPermission( - customer.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access customer ${appUserId} for project ${customer.projectId}`, - ); - return customer; - }), - - getCustomersUnlockedPerks: (customerId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const [customer, perks] = yield* Effect.all( - [ - customerRepository.getCustomerById(customerId), - customerRepository.getCustomersUnlockedPerks(customerId), - ], - { - concurrency: "unbounded", - }, - ); - if (!customer) { - return yield* Effect.fail( - new CustomerNotFoundError({ - message: "Customer not found", - }), - ); - } - yield* checkProjectPermission( - customer.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access customer ${customerId} for project ${customer.projectId}`, - ); - return perks; - }), - - getCustomerPurchases: (customerId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const customer = - yield* customerRepository.getCustomerById(customerId); - if (!customer) { - return yield* Effect.fail( - new CustomerNotFoundError({ - message: "Customer not found", - }), - ); - } - yield* checkProjectPermission( - customer.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access customer ${customerId} for project ${customer.projectId}`, - ); - return yield* customerRepository.getCustomerPurchases(customerId); - }), - - mergeCustomers: (fromCustomerId: string, toCustomerId: string) => - Effect.gen(function* () { - const customerRepository = yield* CustomerRepository; - - return yield* customerRepository.updateCustomer({ - id: fromCustomerId, - parentCustomerId: toCustomerId, - archivedAt: new Date(), - }); - - // TODO: Update all the customer's subscriptions to the new customer - // TODO: Update all the customer's purchases to the new customer - // TODO: Update all the customer's unlocked perks to the new customer - // TODO: Update all the customer's external identifiers to the new customer - // TODO: Update all the customer's transactions to the new customer - }), - }; - }), - }, + 'CustomerService', + { + dependencies: [CustomerRepository.Default], + effect: Effect.gen(function* () { + const customerRepository = yield* CustomerRepository; + return { + // createCustomer: (input: { + // projectId: string; + // appUserId: string; + // name?: string | null; + // email?: string | null; + // attributes?: Record; + // origin: CustomerOriginValue; + // }) => + // Effect.gen(function* () { + // const session = yield* AuthSession; + // const environment = yield* Environment; + // const customerRepository = yield* CustomerRepository; + + // // SECURITY: Authorization check + // yield* checkProjectPermission( + // input.projectId, + // 'project:all', + // `User ${session?.user?.id} is not authorized to create customers for project ${input.projectId}` + // ); + + // const newCustomer = { + // id: generateId('customer'), + // projectId: input.projectId, + // appUserId: input.appUserId, + // type: CustomerType.Identified, + // name: input.name ?? null, + // email: input.email ?? null, + // parentCustomerId: null, + // origin: input.origin, + // environment + // } satisfies InsertCustomer; + + // yield* customerRepository.createCustomer(newCustomer); + // return { + // ...newCustomer, + // createdAt: new Date(), + // updatedAt: new Date() + // }; + // }), + + createCustomer: (input: { + projectId: string; + appUserId: string; + origin: CustomerOriginValue; + environment: EnvironmentValue; + }) => + Effect.gen(function* () { + const customerRepository = yield* CustomerRepository; + + if (!input.appUserId.startsWith(ANONYMOUS_USER_ID_PREFIX)) { + return yield* Effect.fail( + new InvalidAnonymousIdError({ + message: `Invalid anonymous id: ${input.appUserId}` + }) + ); + } + + const newCustomer = { + id: generateId('customer'), + type: input.appUserId.startsWith(ANONYMOUS_USER_ID_PREFIX) + ? CustomerType.Anonymous + : CustomerType.Identified, + parentCustomerId: null, + projectId: input.projectId, + appUserId: input.appUserId, + origin: input.origin, + environment: input.environment, + name: null, + email: null, + additionalAttributes: {} + // TODO: Figure out how to handle attributes + } satisfies InsertCustomer; + + yield* customerRepository.createCustomer(newCustomer); + + yield* Effect.log( + `Created customer ${newCustomer.id} (${newCustomer.type === CustomerType.Anonymous ? 'anonymous' : 'identified'}) for app user ${input.appUserId}` + ); + + return yield* Effect.succeed({ + ...newCustomer, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + }); + }), + + getCustomers: ({ + projectId, + type + }: { + projectId: string; + type?: CustomerTypeValue; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + yield* checkProjectPermission( + projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access customers for project ${projectId}` + ); + return yield* customerRepository.getCustomers({ + projectId, + environment, + type: type ?? null + }); + }), + getCustomerById: (id: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const customer = yield* customerRepository.getCustomerById(id); + if (!customer) { + return yield* Effect.fail( + new CustomerNotFoundError({ + message: 'Customer not found' + }) + ); + } + yield* checkProjectPermission( + customer.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access customer ${id} for project ${customer.projectId}` + ); + return customer; + }), + + getCustomerByAppUserId: (appUserId: string) => + Effect.gen(function* () { + const environment = yield* Environment; + const session = yield* AuthSession; + const projectId = session?.projects[0]?.id; + if (!projectId) { + return yield* Effect.dieMessage( + 'Project ID not found after authentication' + ); + } + const customer = yield* customerRepository.getCustomerByAppUserId({ + projectId, + appUserId, + environment + }); + if (!customer) { + return yield* Effect.fail( + new CustomerNotFoundError({ + message: 'Customer not found' + }) + ); + } + yield* checkProjectPermission( + customer.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access customer ${appUserId} for project ${customer.projectId}` + ); + return customer; + }), + + getCustomersUnlockedPerks: (customerId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const [customer, perks] = yield* Effect.all( + [ + customerRepository.getCustomerById(customerId), + customerRepository.getCustomersUnlockedPerks(customerId) + ], + { + concurrency: 'unbounded' + } + ); + if (!customer) { + return yield* Effect.fail( + new CustomerNotFoundError({ + message: 'Customer not found' + }) + ); + } + yield* checkProjectPermission( + customer.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access customer ${customerId} for project ${customer.projectId}` + ); + return perks; + }), + + getCustomerPurchases: (customerId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const customer = + yield* customerRepository.getCustomerById(customerId); + if (!customer) { + return yield* Effect.fail( + new CustomerNotFoundError({ + message: 'Customer not found' + }) + ); + } + yield* checkProjectPermission( + customer.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access customer ${customerId} for project ${customer.projectId}` + ); + return yield* customerRepository.getCustomerPurchases(customerId); + }), + + mergeCustomers: (fromCustomerId: string, toCustomerId: string) => + Effect.gen(function* () { + const customerRepository = yield* CustomerRepository; + + return yield* customerRepository.updateCustomer({ + id: fromCustomerId, + parentCustomerId: toCustomerId, + archivedAt: new Date() + }); + + // TODO: Update all the customer's subscriptions to the new customer + // TODO: Update all the customer's purchases to the new customer + // TODO: Update all the customer's unlocked perks to the new customer + // TODO: Update all the customer's external identifiers to the new customer + // TODO: Update all the customer's transactions to the new customer + }) + }; + }) + } ) {} diff --git a/apps/web/lib/services/environment.service.ts b/apps/web/lib/services/environment.service.ts index e661c60ba..46956f514 100644 --- a/apps/web/lib/services/environment.service.ts +++ b/apps/web/lib/services/environment.service.ts @@ -1,337 +1,333 @@ -import { Context, Data, Effect } from "effect"; -import { AuthSession } from "./auth.service"; -import { Cookies } from "../effect/cookies"; import { - Environment as EnvironmentEnum, - EnvironmentValue, -} from "@voidhash/lib/constants"; -import { ProjectRepository } from "../repositories/project.repository"; -import { OrganizationRepository } from "../repositories/organization.repository"; -import { checkProjectPermission } from "../effect/permissions"; -import { HonoRuntimeTag, NextjsRuntimeTag } from "../effect/runtimes/tags"; + Environment as EnvironmentEnum, + type EnvironmentValue +} from '@voidhash/lib/constants'; +import { Context, Data, Effect } from 'effect'; +import { Cookies } from '../effect/cookies'; +import { checkProjectPermission } from '../effect/permissions'; +import { HonoRuntimeTag, NextjsRuntimeTag } from '../effect/runtimes/tags'; +import { OrganizationRepository } from '../repositories/organization.repository'; +import { ProjectRepository } from '../repositories/project.repository'; +import { AuthSession } from './auth.service'; export class MissingEnvironmentError extends Data.TaggedError( - "MissingEnvironmentError" + 'MissingEnvironmentError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class InvalidEnvironmentError extends Data.TaggedError( - "InvalidEnvironmentError" + 'InvalidEnvironmentError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class EnvironmentCookieNotFoundError extends Data.TaggedError( - "EnvironmentCookieNotFoundError" + 'EnvironmentCookieNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class ProjectNotFoundInSessionError extends Data.TaggedError( - "ProjectNotFoundInSessionError" + 'ProjectNotFoundInSessionError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class OrganizationNotFoundInSessionError extends Data.TaggedError( - "OrganizationNotFoundInSessionError" + 'OrganizationNotFoundInSessionError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class ProjectNotFoundError extends Data.TaggedError( - "ProjectNotFoundError" + 'ProjectNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class OrganizationNotFoundError extends Data.TaggedError( - "OrganizationNotFoundError" + 'OrganizationNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class OrganizationWithoutSlugError extends Data.TaggedError( - "OrganizationWithoutSlugError" + 'OrganizationWithoutSlugError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} type EnvironmentRetrievalOptions = - | { - projectId: string; - } - | { - projectSlug: string; - organizationSlug: string; - } - | { - projectSlug: string; - organizationId: string; - }; + | { + projectId: string; + } + | { + projectSlug: string; + organizationSlug: string; + } + | { + projectSlug: string; + organizationId: string; + }; -export class Environment extends Context.Tag("app/Environment")< - Environment, - EnvironmentValue +export class Environment extends Context.Tag('app/Environment')< + Environment, + EnvironmentValue >() { - public static readonly provide = ( - environment: EnvironmentValue - ): (( - self: Effect.Effect - ) => Effect.Effect>) => - Effect.provideService(this, environment); + static readonly provide = ( + environment: EnvironmentValue + ): (( + self: Effect.Effect + ) => Effect.Effect>) => + Effect.provideService(this, environment); } export class EnvironmentService extends Effect.Service()( - "app/EnvironmentService", - { - dependencies: [], + 'app/EnvironmentService', + { + dependencies: [], - effect: Effect.gen(function* () { - return { - getEnvironmentFromCookie: (options: EnvironmentRetrievalOptions) => - Effect.gen(function* () { - yield* NextjsRuntimeTag; - const session = yield* AuthSession; - if (session.environment) { - return session.environment; - } - // If user is authenticated with session, we can use cookies to attempt to retrieve the environment. With api-keys, the environment is already set. + effect: Effect.gen(function* () { + return { + getEnvironmentFromCookie: (options: EnvironmentRetrievalOptions) => + Effect.gen(function* () { + yield* NextjsRuntimeTag; + const session = yield* AuthSession; + if (session.environment) { + return session.environment; + } + // If user is authenticated with session, we can use cookies to attempt to retrieve the environment. With api-keys, the environment is already set. - if ("projectId" in options) { - return yield* retrieveEnvironmentFromProjectId(options.projectId); - } else if ( - "projectSlug" in options && - "organizationSlug" in options - ) { - return yield* retrieveEnvironmentFromProjectSlugAndOrganizationSlug( - options.projectSlug, - options.organizationSlug - ); - } else if ( - "projectSlug" in options && - "organizationId" in options - ) { - return yield* retrieveEnvironmentFromProjectSlugAndOrganizationId( - options.projectSlug, - options.organizationId - ); - } + if ('projectId' in options) { + return yield* retrieveEnvironmentFromProjectId(options.projectId); + } + if ('projectSlug' in options && 'organizationSlug' in options) { + return yield* retrieveEnvironmentFromProjectSlugAndOrganizationSlug( + options.projectSlug, + options.organizationSlug + ); + } + if ('projectSlug' in options && 'organizationId' in options) { + return yield* retrieveEnvironmentFromProjectSlugAndOrganizationId( + options.projectSlug, + options.organizationId + ); + } - return yield* Effect.fail( - new MissingEnvironmentError({ - message: "Environment is not specified", - }) - ); - }), + return yield* Effect.fail( + new MissingEnvironmentError({ + message: 'Environment is not specified' + }) + ); + }), - getEnvironmentFromApiAuthSession: () => - Effect.gen(function* () { - yield* HonoRuntimeTag; - const session = yield* AuthSession; - if ( - session.method !== "api-key" && - session.method !== "publishable-api-key" - ) { - return yield* Effect.dieMessage( - "Tried to get environment from api auth session, but session is not an api key" - ); - } - return session.environment; - }), + getEnvironmentFromApiAuthSession: () => + Effect.gen(function* () { + yield* HonoRuntimeTag; + const session = yield* AuthSession; + if ( + session.method !== 'api-key' && + session.method !== 'publishable-api-key' + ) { + return yield* Effect.dieMessage( + 'Tried to get environment from api auth session, but session is not an api key' + ); + } + return session.environment; + }), - switchEnvironment: (input: { - projectId: string; - environment: EnvironmentValue; - }) => - Effect.gen(function* () { - yield* NextjsRuntimeTag; - const session = yield* AuthSession; - const projectRepository = yield* ProjectRepository; - const organizationRepository = yield* OrganizationRepository; - yield* checkProjectPermission( - input.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to switch environment for project ${input.projectId}` - ); - const project = yield* projectRepository.getProjectById( - input.projectId - ); - if (!project) { - return yield* Effect.fail( - new ProjectNotFoundError({ - message: `Project ${input.projectId} not found`, - }) - ); - } - const organization = - yield* organizationRepository.getOrganizationById( - project.organizationId - ); - if (!organization) { - return yield* Effect.fail( - new OrganizationNotFoundError({ - message: `Organization ${project.organizationId} not found`, - }) - ); - } - if (!organization.slug) { - return yield* Effect.fail( - new OrganizationWithoutSlugError({ - message: `Organization ${project.organizationId} has no slug`, - }) - ); - } - yield* setEnvironmentCookie( - organization.slug, - project.slug, - input.environment - ); - }), - }; - }), - } + switchEnvironment: (input: { + projectId: string; + environment: EnvironmentValue; + }) => + Effect.gen(function* () { + yield* NextjsRuntimeTag; + const session = yield* AuthSession; + const projectRepository = yield* ProjectRepository; + const organizationRepository = yield* OrganizationRepository; + yield* checkProjectPermission( + input.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to switch environment for project ${input.projectId}` + ); + const project = yield* projectRepository.getProjectById( + input.projectId + ); + if (!project) { + return yield* Effect.fail( + new ProjectNotFoundError({ + message: `Project ${input.projectId} not found` + }) + ); + } + const organization = + yield* organizationRepository.getOrganizationById( + project.organizationId + ); + if (!organization) { + return yield* Effect.fail( + new OrganizationNotFoundError({ + message: `Organization ${project.organizationId} not found` + }) + ); + } + if (!organization.slug) { + return yield* Effect.fail( + new OrganizationWithoutSlugError({ + message: `Organization ${project.organizationId} has no slug` + }) + ); + } + yield* setEnvironmentCookie( + organization.slug, + project.slug, + input.environment + ); + }) + }; + }) + } ) {} const setEnvironmentCookie = ( - organizationSlug: string, - projectSlug: string, - environment: EnvironmentValue + organizationSlug: string, + projectSlug: string, + environment: EnvironmentValue ) => - Effect.gen(function* () { - const cookies = yield* Cookies; - yield* cookies.setCookie( - `project_environment_${organizationSlug}:${projectSlug}`, - environment.toString() - ); - }); + Effect.gen(function* () { + const cookies = yield* Cookies; + yield* cookies.setCookie( + `project_environment_${organizationSlug}:${projectSlug}`, + environment.toString() + ); + }); const getEnvironmentFromCookie = ( - organizationSlug: string, - projectSlug: string + organizationSlug: string, + projectSlug: string ) => - Effect.gen(function* () { - const cookies = yield* Cookies; - const projectEnvironmentCookie = yield* cookies.getCookie( - `project_environment_${organizationSlug}:${projectSlug}` - ); - if (!projectEnvironmentCookie) { - return yield* Effect.fail( - new EnvironmentCookieNotFoundError({ - message: "Environment cookie not found", - }) - ); - } - return yield* validateEnvironment( - Number.parseInt(projectEnvironmentCookie) - ); - }); + Effect.gen(function* () { + const cookies = yield* Cookies; + const projectEnvironmentCookie = yield* cookies.getCookie( + `project_environment_${organizationSlug}:${projectSlug}` + ); + if (!projectEnvironmentCookie) { + return yield* Effect.fail( + new EnvironmentCookieNotFoundError({ + message: 'Environment cookie not found' + }) + ); + } + return yield* validateEnvironment( + Number.parseInt(projectEnvironmentCookie, 10) + ); + }); const retrieveEnvironmentFromProjectId = (projectId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const project = session.projects.find((p) => p.id === projectId); - const organization = session.organizations.find( - (o) => o.id === project?.organizationId - ); - if (!project) { - return yield* Effect.fail( - new ProjectNotFoundInSessionError({ - message: "Project not found in session", - }) - ); - } - if (!organization) { - return yield* Effect.fail( - new OrganizationNotFoundInSessionError({ - message: "Organization not found in session", - }) - ); - } - return yield* getEnvironmentFromCookie(organization.slug, project.slug); - }); + Effect.gen(function* () { + const session = yield* AuthSession; + const project = session.projects.find((p) => p.id === projectId); + const organization = session.organizations.find( + (o) => o.id === project?.organizationId + ); + if (!project) { + return yield* Effect.fail( + new ProjectNotFoundInSessionError({ + message: 'Project not found in session' + }) + ); + } + if (!organization) { + return yield* Effect.fail( + new OrganizationNotFoundInSessionError({ + message: 'Organization not found in session' + }) + ); + } + return yield* getEnvironmentFromCookie(organization.slug, project.slug); + }); const retrieveEnvironmentFromProjectSlugAndOrganizationSlug = ( - projectSlug: string, - organizationSlug: string + projectSlug: string, + organizationSlug: string ) => - Effect.gen(function* () { - const session = yield* AuthSession; - const projects = session.projects.filter((p) => p.slug === projectSlug); - const projectOrgIds = projects.map((p) => p.organizationId); - const organizations = session.organizations.filter( - (o) => o.slug === organizationSlug && projectOrgIds.includes(o.id) - ); + Effect.gen(function* () { + const session = yield* AuthSession; + const projects = session.projects.filter((p) => p.slug === projectSlug); + const projectOrgIds = projects.map((p) => p.organizationId); + const organizations = session.organizations.filter( + (o) => o.slug === organizationSlug && projectOrgIds.includes(o.id) + ); - const organization = organizations[0]; - if (!organization) { - return yield* Effect.fail( - new OrganizationNotFoundInSessionError({ - message: "Organization not found in session", - }) - ); - } + const organization = organizations[0]; + if (!organization) { + return yield* Effect.fail( + new OrganizationNotFoundInSessionError({ + message: 'Organization not found in session' + }) + ); + } - const project = projects.find((p) => p.organizationId === organization.id); - if (!project) { - return yield* Effect.fail( - new ProjectNotFoundInSessionError({ - message: "Project not found in session", - }) - ); - } - return yield* getEnvironmentFromCookie(organization.slug, project.slug); - }); + const project = projects.find((p) => p.organizationId === organization.id); + if (!project) { + return yield* Effect.fail( + new ProjectNotFoundInSessionError({ + message: 'Project not found in session' + }) + ); + } + return yield* getEnvironmentFromCookie(organization.slug, project.slug); + }); const retrieveEnvironmentFromProjectSlugAndOrganizationId = ( - projectSlug: string, - organizationId: string + projectSlug: string, + organizationId: string ) => - Effect.gen(function* () { - const session = yield* AuthSession; - const project = session.projects.find( - (p) => p.slug === projectSlug && p.organizationId === organizationId - ); - if (!project) { - return yield* Effect.fail( - new ProjectNotFoundInSessionError({ - message: "Project not found in session", - }) - ); - } - const organization = session.organizations.find( - (o) => o.id === organizationId - ); - if (!organization) { - return yield* Effect.fail( - new OrganizationNotFoundInSessionError({ - message: "Organization not found in session", - }) - ); - } - return yield* getEnvironmentFromCookie(organization.slug, project.slug); - }); + Effect.gen(function* () { + const session = yield* AuthSession; + const project = session.projects.find( + (p) => p.slug === projectSlug && p.organizationId === organizationId + ); + if (!project) { + return yield* Effect.fail( + new ProjectNotFoundInSessionError({ + message: 'Project not found in session' + }) + ); + } + const organization = session.organizations.find( + (o) => o.id === organizationId + ); + if (!organization) { + return yield* Effect.fail( + new OrganizationNotFoundInSessionError({ + message: 'Organization not found in session' + }) + ); + } + return yield* getEnvironmentFromCookie(organization.slug, project.slug); + }); const validateEnvironment = (environment: number) => - Effect.gen(function* () { - if ( - environment !== EnvironmentEnum.Production && - environment !== EnvironmentEnum.Testing - ) { - return yield* Effect.fail( - new InvalidEnvironmentError({ - message: `Invalid environment: ${environment}`, - }) - ); - } - return environment satisfies EnvironmentValue; - }); + Effect.gen(function* () { + if ( + environment !== EnvironmentEnum.Production && + environment !== EnvironmentEnum.Testing + ) { + return yield* Effect.fail( + new InvalidEnvironmentError({ + message: `Invalid environment: ${environment}` + }) + ); + } + return environment satisfies EnvironmentValue; + }); diff --git a/apps/web/lib/services/helpers/sdk/load-sdk-headers.ts b/apps/web/lib/services/helpers/sdk/load-sdk-headers.ts new file mode 100644 index 000000000..ba270b2c2 --- /dev/null +++ b/apps/web/lib/services/helpers/sdk/load-sdk-headers.ts @@ -0,0 +1,26 @@ +import { Schema } from 'effect'; + +const SdkHeaders = Schema.Struct({ + 'x-app-user-id': Schema.String, + 'x-publishable-key': Schema.String, + 'x-platform': Schema.String, + 'x-sdk': Schema.Literal('react-native'), + 'x-sdk-version': Schema.String, + 'x-platform-flavor': Schema.Literal('native'), + 'x-platform-flavor-version': Schema.optional(Schema.String), + 'x-platform-version': Schema.optional(Schema.String), + 'x-platform-device': Schema.optional(Schema.String), + 'x-platform-brand': Schema.optional(Schema.String), + 'x-preferred-locales': Schema.optional(Schema.String), + 'x-client-locale': Schema.optional(Schema.String), + 'x-client-version': Schema.optional(Schema.String), + 'x-client-bundle-id': Schema.String, + 'x-observer-mode': Schema.Literal('false'), + 'x-nonce': Schema.optional(Schema.String), + 'x-storefront': Schema.optional(Schema.String), + 'x-is-debug-build': Schema.Literal('true', 'false'), + 'x-is-backgrounded': Schema.Literal('false') +}); + +export const parseSdkHeaders = (headers: Headers) => + Schema.decodeUnknownSync(SdkHeaders)(Object.fromEntries(headers)); diff --git a/apps/web/lib/services/organization.service.ts b/apps/web/lib/services/organization.service.ts index 0ba2fd5ea..0ee6d11b2 100644 --- a/apps/web/lib/services/organization.service.ts +++ b/apps/web/lib/services/organization.service.ts @@ -1,235 +1,234 @@ -import { Data, Effect, Either } from "effect"; - -import { AuthSession } from "@/lib/services/auth.service"; -import { OrganizationRepository } from "../repositories/organization.repository"; -import { checkOrganizationPermission } from "@/lib/effect/permissions"; -import { BetterAuth } from "@/lib/effect/better-auth"; -import { Request } from "@/lib/effect/request"; -import { createShortId, createSlug } from "@voidhash/lib/functions"; -import { SLUG_BLACKLIST } from "@voidhash/lib/constants"; +import { SLUG_BLACKLIST } from '@voidhash/lib/constants'; +import { createShortId, createSlug } from '@voidhash/lib/functions'; +import { Data, Effect, Either } from 'effect'; +import { BetterAuth } from '@/lib/effect/better-auth'; +import { checkOrganizationPermission } from '@/lib/effect/permissions'; +import { Request } from '@/lib/effect/request'; +import { AuthSession } from '@/lib/services/auth.service'; +import { OrganizationRepository } from '../repositories/organization.repository'; export class FailedToCreateOrganizationError extends Data.TaggedError( - "FailedToCreateOrganizationError", + 'FailedToCreateOrganizationError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class UserSessionNotFoundError extends Data.TaggedError( - "UserSessionNotFoundError", + 'UserSessionNotFoundError' )<{ - readonly message: string; + readonly message: string; }> {} export class OrganizationNotFound extends Data.TaggedError( - "OrganizationNotFound", + 'OrganizationNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class OrganizationService extends Effect.Service()( - "OrganizationService", - { - effect: Effect.gen(function* () { - const checkSlugAvailable = (slug: string) => - Effect.gen(function* () { - const betterAuth = yield* BetterAuth; - const request = yield* Request; - const headers = yield* request.getHeaders; - const res = yield* Effect.either( - betterAuth.use(async (client) => - client.api.checkOrganizationSlug({ - headers, - body: { slug }, - }), - ), - ); - - if (Either.isLeft(res)) { - const error = res.left; - if ( - error.cause && - error.cause && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error.cause as any).body?.code === "SLUG_IS_TAKEN" - ) { - return yield* Effect.succeed(false); - } - return yield* Effect.fail(res.left); - } - - return yield* Effect.succeed(true); - }); - - return { - createOrganization: (input: { name: string }) => - Effect.gen(function* () { - const betterAuth = yield* BetterAuth; - const request = yield* Request; - const session = yield* AuthSession; - - let slug = createSlug(input.name); - if (SLUG_BLACKLIST.includes(slug)) { - slug = slug + "-" + createShortId(); - } - - const slugIsAvailable = yield* checkSlugAvailable(slug); - if (!slugIsAvailable) { - slug = slug + "-" + createShortId(); - } - - const headers = yield* request.getHeaders; - const organization = yield* betterAuth.use(async (client) => - client.api.createOrganization({ - headers, - body: { - name: input.name, - slug, - }, - }), - ); - if (!organization) { - return yield* Effect.fail( - new FailedToCreateOrganizationError({ - message: "Failed to create organization", - }), - ); - } - - const email = session?.user?.email; - if (!email) { - return yield* Effect.fail( - new UserSessionNotFoundError({ - message: "User session not found", - }), - ); - } - - return yield* Effect.succeed({ - id: organization.id, - name: organization.name, - slug, - }); - }), - - getOrganizationBySlug: (slug: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const organizationRepository = yield* OrganizationRepository; - const organization = - yield* organizationRepository.getOrganizationBySlug(slug); - if (!organization) { - return yield* Effect.fail( - new OrganizationNotFound({ - message: `Organization with slug ${slug} not found`, - }), - ); - } - // SECURITY: Authorization check - yield* checkOrganizationPermission( - organization.id, - "organization:all", - `User ${session?.user?.id} is not authorized to access organization ${organization.id}`, - ); - - return organization; - }), - - getOrganizationById: (id: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const organizationRepository = yield* OrganizationRepository; - - const organization = - yield* organizationRepository.getOrganizationById(id); - if (!organization) { - return yield* Effect.fail( - new OrganizationNotFound({ - message: `Organization with id ${id} not found`, - }), - ); - } - - // SECURITY: Authorization check - yield* checkOrganizationPermission( - id, - "organization:all", - `User ${session?.user?.id} is not authorized to access organization ${id}`, - ); - - return organization; - }), - - deleteOrganization: (input: { organizationId: string }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const request = yield* Request; - - // SECURITY: Authorization check - yield* checkOrganizationPermission( - input.organizationId, - "organization:all", - `User ${session?.user?.id} is not authorized to delete organization ${input.organizationId}`, - ); - - const betterAuth = yield* BetterAuth; - const headers = yield* request.getHeaders; - yield* betterAuth.use(async (client) => - client.api.deleteOrganization({ - headers, - body: { organizationId: input.organizationId }, - }), - ); - - return yield* Effect.succeed(undefined); - }), - - updateOrganization: (input: { organizationId: string; name: string }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const request = yield* Request; - const organizationRepository = yield* OrganizationRepository; - - const organization = - yield* organizationRepository.getOrganizationById( - input.organizationId, - ); - if (!organization) { - return yield* Effect.fail( - new OrganizationNotFound({ - message: `Organization with id ${input.organizationId} not found`, - }), - ); - } - - // SECURITY: Authorization check - yield* checkOrganizationPermission( - input.organizationId, - "organization:all", - `User ${session?.user?.id} is not authorized to update organization ${input.organizationId}`, - ); - - const betterAuth = yield* BetterAuth; - const headers = yield* request.getHeaders; - yield* betterAuth.use(async (client) => - client.api.updateOrganization({ - headers, - body: { - organizationId: input.organizationId, - data: { - name: input.name, - }, - }, - }), - ); - - return yield* Effect.succeed(undefined); - }), - }; - }), - - // Specify dependencies - dependencies: [], - }, + 'OrganizationService', + { + effect: Effect.gen(function* () { + const checkSlugAvailable = (slug: string) => + Effect.gen(function* () { + const betterAuth = yield* BetterAuth; + const request = yield* Request; + const headers = yield* request.getHeaders(); + const res = yield* Effect.either( + betterAuth.use(async (client) => + client.api.checkOrganizationSlug({ + headers, + body: { slug } + }) + ) + ); + + if (Either.isLeft(res)) { + const error = res.left; + if ( + error.cause && + error.cause && + // biome-ignore lint/suspicious/noExplicitAny: is ok + (error.cause as any).body?.code === 'SLUG_IS_TAKEN' + ) { + return yield* Effect.succeed(false); + } + return yield* Effect.fail(res.left); + } + + return yield* Effect.succeed(true); + }); + + return { + createOrganization: (input: { name: string }) => + Effect.gen(function* () { + const betterAuth = yield* BetterAuth; + const request = yield* Request; + const session = yield* AuthSession; + + let slug = createSlug(input.name); + if (SLUG_BLACKLIST.includes(slug)) { + slug = `${slug}-${createShortId()}`; + } + + const slugIsAvailable = yield* checkSlugAvailable(slug); + if (!slugIsAvailable) { + slug = `${slug}-${createShortId()}`; + } + + const headers = yield* request.getHeaders(); + const organization = yield* betterAuth.use(async (client) => + client.api.createOrganization({ + headers, + body: { + name: input.name, + slug + } + }) + ); + if (!organization) { + return yield* Effect.fail( + new FailedToCreateOrganizationError({ + message: 'Failed to create organization' + }) + ); + } + + const email = session?.user?.email; + if (!email) { + return yield* Effect.fail( + new UserSessionNotFoundError({ + message: 'User session not found' + }) + ); + } + + return yield* Effect.succeed({ + id: organization.id, + name: organization.name, + slug + }); + }), + + getOrganizationBySlug: (slug: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const organizationRepository = yield* OrganizationRepository; + const organization = + yield* organizationRepository.getOrganizationBySlug(slug); + if (!organization) { + return yield* Effect.fail( + new OrganizationNotFound({ + message: `Organization with slug ${slug} not found` + }) + ); + } + // SECURITY: Authorization check + yield* checkOrganizationPermission( + organization.id, + 'organization:all', + `User ${session?.user?.id} is not authorized to access organization ${organization.id}` + ); + + return organization; + }), + + getOrganizationById: (id: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const organizationRepository = yield* OrganizationRepository; + + const organization = + yield* organizationRepository.getOrganizationById(id); + if (!organization) { + return yield* Effect.fail( + new OrganizationNotFound({ + message: `Organization with id ${id} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkOrganizationPermission( + id, + 'organization:all', + `User ${session?.user?.id} is not authorized to access organization ${id}` + ); + + return organization; + }), + + deleteOrganization: (input: { organizationId: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const request = yield* Request; + + // SECURITY: Authorization check + yield* checkOrganizationPermission( + input.organizationId, + 'organization:all', + `User ${session?.user?.id} is not authorized to delete organization ${input.organizationId}` + ); + + const betterAuth = yield* BetterAuth; + const headers = yield* request.getHeaders(); + yield* betterAuth.use(async (client) => + client.api.deleteOrganization({ + headers, + body: { organizationId: input.organizationId } + }) + ); + + return yield* Effect.succeed(undefined); + }), + + updateOrganization: (input: { organizationId: string; name: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const request = yield* Request; + const organizationRepository = yield* OrganizationRepository; + + const organization = + yield* organizationRepository.getOrganizationById( + input.organizationId + ); + if (!organization) { + return yield* Effect.fail( + new OrganizationNotFound({ + message: `Organization with id ${input.organizationId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkOrganizationPermission( + input.organizationId, + 'organization:all', + `User ${session?.user?.id} is not authorized to update organization ${input.organizationId}` + ); + + const betterAuth = yield* BetterAuth; + const headers = yield* request.getHeaders(); + yield* betterAuth.use(async (client) => + client.api.updateOrganization({ + headers, + body: { + organizationId: input.organizationId, + data: { + name: input.name + } + } + }) + ); + + return yield* Effect.succeed(undefined); + }) + }; + }), + + // Specify dependencies + dependencies: [] + } ) {} diff --git a/apps/web/lib/services/payment-provider-core.service.ts b/apps/web/lib/services/payment-provider-core.service.ts index 837e21a37..3ceae8302 100644 --- a/apps/web/lib/services/payment-provider-core.service.ts +++ b/apps/web/lib/services/payment-provider-core.service.ts @@ -1,337 +1,338 @@ -import { Data, Effect, pipe } from "effect"; -import { Db, TransactionContext } from "@/lib/effect/db"; -import { CustomerRepository } from "@/lib/repositories/customer.repository"; -import { PaymentProviderConfigurationProductRepository } from "@/lib/repositories/payment-provider-configuration-product.repository"; -import { SubscriptionRepository } from "@/lib/repositories/subscription.repository"; -import { ProviderEnvironmentValue, InsertSubscription } from "@voidhash/db"; -import { SubscriptionStatus } from "@voidhash/lib/constants"; -import { generateId } from "@/lib/id/generate"; -import { PerkGrantService } from "./perk-grant.service"; +import type { + InsertSubscription, + ProviderEnvironmentValue +} from '@voidhash/db'; +import { SubscriptionStatus } from '@voidhash/lib/constants'; +import { Data, Effect, pipe } from 'effect'; +import { Db, TransactionContext } from '@/lib/effect/db'; +import { generateId } from '@/lib/id/generate'; +import { CustomerRepository } from '@/lib/repositories/customer.repository'; +import { PaymentProviderConfigurationProductRepository } from '@/lib/repositories/payment-provider-configuration-product.repository'; +import { SubscriptionRepository } from '@/lib/repositories/subscription.repository'; +import { PerkGrantService } from './perk-grant.service'; export class CustomerNotFoundError extends Data.TaggedError( - "CustomerNotFoundError" + 'CustomerNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class SubscriptionWithSameStoreSubscriptionIdAlreadyExistsError extends Data.TaggedError( - "SubscriptionWithSameStoreSubscriptionIdAlreadyExistsError" + 'SubscriptionWithSameStoreSubscriptionIdAlreadyExistsError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class SubscriptionWithSameInitialTransactionIdAlreadyExistsError extends Data.TaggedError( - "SubscriptionWithSameInitialTransactionIdAlreadyExistsError" + 'SubscriptionWithSameInitialTransactionIdAlreadyExistsError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class PaymentProviderConfigurationProductNotFoundError extends Data.TaggedError( - "PaymentProviderConfigurationProductNotFoundError" + 'PaymentProviderConfigurationProductNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class SubscriptionNotFoundError extends Data.TaggedError( - "SubscriptionNotFoundError" + 'SubscriptionNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class PaymentProviderCoreService extends Effect.Service()( - "PaymentProviderCoreService", - { - dependencies: [], - effect: Effect.gen(function* () { - return { - /** - * Create a subscription for a customer - * @param input - The input for the subscription creation - * @returns The created subscription - */ - createSubscription: (input: { - /** - * The customer id - */ - customerId: string; - /** - * The transaction id - */ - transactionId: string; - /** - * The store subscription id - this is the id of the subscription in the payment provider - */ - storeSubscriptionId: string; - /** - * The payment provider configuration product id - */ - paymentProviderConfigurationProductId: string; - /** - * Whether the subscription is a trial - */ - isTrial: boolean; - /** - * The date the subscription was purchased - */ - purchasedAt: Date; - /** - * The date the subscription starts - */ - startsAt: Date; - /** - * The date the subscription was canceled - */ - canceledAt: Date | null; - /** - * Whether the subscription is canceled at the end of the period - */ - cancelAtPeriodEnd: boolean; - /** - * The date the subscription expires - */ - expiresAt: Date | null; - /** - * The provider environment - */ - providerEnvironment: ProviderEnvironmentValue; - }) => - Effect.gen(function* () { - const customerRepository = yield* CustomerRepository; - const subscriptionRepository = yield* SubscriptionRepository; - const paymentProviderConfigurationProductRepository = - yield* PaymentProviderConfigurationProductRepository; - const perkGrantService = yield* PerkGrantService; - const db = yield* Db; - - const [customer, paymentProviderProduct] = yield* Effect.all([ - customerRepository.getCustomerById(input.customerId), - paymentProviderConfigurationProductRepository.getProviderProductById( - input.paymentProviderConfigurationProductId - ), - ]); - - if (!customer) { - return yield* Effect.fail( - new CustomerNotFoundError({ - message: `Customer with id ${input.customerId} not found`, - }) - ); - } - - if (!paymentProviderProduct) { - return yield* Effect.fail( - new PaymentProviderConfigurationProductNotFoundError({ - message: `Payment provider configuration product with id ${input.paymentProviderConfigurationProductId} not found`, - }) - ); - } - - const newSubscription = { - id: generateId("subscription"), - status: SubscriptionStatus.Active, - customerId: input.customerId, - initialTransactionId: input.transactionId, - latestTransactionId: input.transactionId, - storeSubscriptionId: input.storeSubscriptionId, - paymentProviderConfigurationProductId: - input.paymentProviderConfigurationProductId, - purchasedAt: input.purchasedAt, - startsAt: input.startsAt, - canceledAt: input.canceledAt, - cancelAtPeriodEnd: input.cancelAtPeriodEnd, - providerEnvironment: input.providerEnvironment, - expiresAt: input.expiresAt, - } satisfies InsertSubscription; - - yield* db.transaction((tx) => - TransactionContext.provide(tx)( - pipe( - assertSubscriptionDoesNotExist( - customer.projectId, - input.transactionId, - input.storeSubscriptionId - ), - Effect.andThen( - subscriptionRepository.createSubscription(newSubscription) - ) - ) - ) - ); - - yield* perkGrantService.syncUnlockedPerks(input.customerId); - }), - - /** - * Renew a subscription - this is used when a subscription is renewed by the customer - * @param input - The input for the subscription renewal - * @returns The renewed subscription - */ - renewSubscription: (input: { - /** - * The transaction id - */ - transactionId: string; - /** - * The subscription id - */ - subscriptionId: string; - /** - * The date the subscription was purchased - */ - renewedAt: Date; - /** - * The date the subscription starts - */ - startsAt: Date; - /** - * The date the subscription expires - */ - expiresAt: Date | null; - }) => - Effect.gen(function* () { - const subscriptionRepository = yield* SubscriptionRepository; - const perkGrantService = yield* PerkGrantService; - - const subscription = - yield* subscriptionRepository.getSubscriptionById( - input.subscriptionId - ); - - if (!subscription) { - return yield* Effect.fail( - new SubscriptionNotFoundError({ - message: `Subscription with id ${input.subscriptionId} not found`, - }) - ); - } - - yield* subscriptionRepository.updateSubscription({ - id: input.subscriptionId, - status: SubscriptionStatus.Active, - latestTransactionId: input.transactionId, - startsAt: input.startsAt, - expiresAt: input.expiresAt, - updatedAt: new Date(), - }); - - yield* perkGrantService.syncUnlockedPerks(subscription.customerId); - }), - - /** - * Mark a subscription as canceled. This does not revoke any perks yet, - * @param input - The input for the subscription cancellation - * @returns The canceled subscription - */ - markSubscriptionAsCanceled: (input: { - subscriptionId: string; - canceledAt: Date; - cancelAtPeriodEnd: boolean; - cancellationReason: string | null; - }) => - Effect.gen(function* () { - const subscriptionRepository = yield* SubscriptionRepository; - - const subscription = - yield* subscriptionRepository.getSubscriptionById( - input.subscriptionId - ); - - if (!subscription) { - return yield* Effect.fail( - new SubscriptionNotFoundError({ - message: `Subscription with id ${input.subscriptionId} not found`, - }) - ); - } - - yield* subscriptionRepository.updateSubscription({ - id: input.subscriptionId, - status: SubscriptionStatus.Canceled, - canceledAt: input.canceledAt, - cancelAtPeriodEnd: input.cancelAtPeriodEnd, - cancellationReason: input.cancellationReason, - updatedAt: new Date(), - }); - }), - - /** - * Mark a subscription as not canceled - * @param input - The input for the subscription un-cancellation - * @returns The un-canceled subscription - */ - markSubscriptionAsNotCanceled: (input: { - subscriptionId: string; - }) => - Effect.gen(function* () { - const subscriptionRepository = yield* SubscriptionRepository; - - const subscription = - yield* subscriptionRepository.getSubscriptionById( - input.subscriptionId - ); - - if (!subscription) { - return yield* Effect.fail( - new SubscriptionNotFoundError({ - message: `Subscription with id ${input.subscriptionId} not found`, - }) - ); - } - - yield* subscriptionRepository.updateSubscription({ - id: input.subscriptionId, - status: SubscriptionStatus.Active, - canceledAt: null, - cancelAtPeriodEnd: false, - cancellationReason: null, - }); - }), - - /** - * Revoke a subscription - this marks the subscription as canceled and expires it. This also revokes all perks linked to the subscription. - * @param input - The input for the subscription revocation - * @returns The revoked subscription - */ - revokeSubscription: (input: { - subscriptionId: string; - revokedAt: Date; - revocationReason: string | null; - }) => - Effect.gen(function* () { - const subscriptionRepository = yield* SubscriptionRepository; - const perkGrantService = yield* PerkGrantService; - - const subscription = - yield* subscriptionRepository.getSubscriptionById( - input.subscriptionId - ); - - if (!subscription) { - return yield* Effect.fail( - new SubscriptionNotFoundError({ - message: `Subscription with id ${input.subscriptionId} not found`, - }) - ); - } - - yield* subscriptionRepository.updateSubscription({ - id: input.subscriptionId, - status: SubscriptionStatus.Canceled, - expiresAt: input.revokedAt, - cancellationReason: input.revocationReason, - updatedAt: new Date(), - }); - - yield* perkGrantService.syncUnlockedPerks(subscription.customerId); - }), - }; - }), - } + 'PaymentProviderCoreService', + { + dependencies: [], + effect: Effect.gen(function* () { + return { + /** + * Create a subscription for a customer + * @param input - The input for the subscription creation + * @returns The created subscription + */ + createSubscription: (input: { + /** + * The customer id + */ + customerId: string; + /** + * The transaction id + */ + transactionId: string; + /** + * The store subscription id - this is the id of the subscription in the payment provider + */ + storeSubscriptionId: string; + /** + * The payment provider configuration product id + */ + paymentProviderConfigurationProductId: string; + /** + * Whether the subscription is a trial + */ + isTrial: boolean; + /** + * The date the subscription was purchased + */ + purchasedAt: Date; + /** + * The date the subscription starts + */ + startsAt: Date; + /** + * The date the subscription was canceled + */ + canceledAt: Date | null; + /** + * Whether the subscription is canceled at the end of the period + */ + cancelAtPeriodEnd: boolean; + /** + * The date the subscription expires + */ + expiresAt: Date | null; + /** + * The provider environment + */ + providerEnvironment: ProviderEnvironmentValue; + }) => + Effect.gen(function* () { + const customerRepository = yield* CustomerRepository; + const subscriptionRepository = yield* SubscriptionRepository; + const paymentProviderConfigurationProductRepository = + yield* PaymentProviderConfigurationProductRepository; + const perkGrantService = yield* PerkGrantService; + const db = yield* Db; + + const [customer, paymentProviderProduct] = yield* Effect.all([ + customerRepository.getCustomerById(input.customerId), + paymentProviderConfigurationProductRepository.getProviderProductById( + input.paymentProviderConfigurationProductId + ) + ]); + + if (!customer) { + return yield* Effect.fail( + new CustomerNotFoundError({ + message: `Customer with id ${input.customerId} not found` + }) + ); + } + + if (!paymentProviderProduct) { + return yield* Effect.fail( + new PaymentProviderConfigurationProductNotFoundError({ + message: `Payment provider configuration product with id ${input.paymentProviderConfigurationProductId} not found` + }) + ); + } + + const newSubscription = { + id: generateId('subscription'), + status: SubscriptionStatus.Active, + customerId: input.customerId, + initialTransactionId: input.transactionId, + latestTransactionId: input.transactionId, + storeSubscriptionId: input.storeSubscriptionId, + paymentProviderConfigurationProductId: + input.paymentProviderConfigurationProductId, + purchasedAt: input.purchasedAt, + startsAt: input.startsAt, + canceledAt: input.canceledAt, + cancelAtPeriodEnd: input.cancelAtPeriodEnd, + providerEnvironment: input.providerEnvironment, + expiresAt: input.expiresAt + } satisfies InsertSubscription; + + yield* db.transaction((tx) => + TransactionContext.provide(tx)( + pipe( + assertSubscriptionDoesNotExist( + customer.projectId, + input.transactionId, + input.storeSubscriptionId + ), + Effect.andThen( + subscriptionRepository.createSubscription(newSubscription) + ) + ) + ) + ); + + yield* perkGrantService.syncUnlockedPerks(input.customerId); + }), + + /** + * Renew a subscription - this is used when a subscription is renewed by the customer + * @param input - The input for the subscription renewal + * @returns The renewed subscription + */ + renewSubscription: (input: { + /** + * The transaction id + */ + transactionId: string; + /** + * The subscription id + */ + subscriptionId: string; + /** + * The date the subscription was purchased + */ + renewedAt: Date; + /** + * The date the subscription starts + */ + startsAt: Date; + /** + * The date the subscription expires + */ + expiresAt: Date | null; + }) => + Effect.gen(function* () { + const subscriptionRepository = yield* SubscriptionRepository; + const perkGrantService = yield* PerkGrantService; + + const subscription = + yield* subscriptionRepository.getSubscriptionById( + input.subscriptionId + ); + + if (!subscription) { + return yield* Effect.fail( + new SubscriptionNotFoundError({ + message: `Subscription with id ${input.subscriptionId} not found` + }) + ); + } + + yield* subscriptionRepository.updateSubscription({ + id: input.subscriptionId, + status: SubscriptionStatus.Active, + latestTransactionId: input.transactionId, + startsAt: input.startsAt, + expiresAt: input.expiresAt, + updatedAt: new Date() + }); + + yield* perkGrantService.syncUnlockedPerks(subscription.customerId); + }), + + /** + * Mark a subscription as canceled. This does not revoke any perks yet, + * @param input - The input for the subscription cancellation + * @returns The canceled subscription + */ + markSubscriptionAsCanceled: (input: { + subscriptionId: string; + canceledAt: Date; + cancelAtPeriodEnd: boolean; + cancellationReason: string | null; + }) => + Effect.gen(function* () { + const subscriptionRepository = yield* SubscriptionRepository; + + const subscription = + yield* subscriptionRepository.getSubscriptionById( + input.subscriptionId + ); + + if (!subscription) { + return yield* Effect.fail( + new SubscriptionNotFoundError({ + message: `Subscription with id ${input.subscriptionId} not found` + }) + ); + } + + yield* subscriptionRepository.updateSubscription({ + id: input.subscriptionId, + status: SubscriptionStatus.Canceled, + canceledAt: input.canceledAt, + cancelAtPeriodEnd: input.cancelAtPeriodEnd, + cancellationReason: input.cancellationReason, + updatedAt: new Date() + }); + }), + + /** + * Mark a subscription as not canceled + * @param input - The input for the subscription un-cancellation + * @returns The un-canceled subscription + */ + markSubscriptionAsNotCanceled: (input: { subscriptionId: string }) => + Effect.gen(function* () { + const subscriptionRepository = yield* SubscriptionRepository; + + const subscription = + yield* subscriptionRepository.getSubscriptionById( + input.subscriptionId + ); + + if (!subscription) { + return yield* Effect.fail( + new SubscriptionNotFoundError({ + message: `Subscription with id ${input.subscriptionId} not found` + }) + ); + } + + yield* subscriptionRepository.updateSubscription({ + id: input.subscriptionId, + status: SubscriptionStatus.Active, + canceledAt: null, + cancelAtPeriodEnd: false, + cancellationReason: null + }); + }), + + /** + * Revoke a subscription - this marks the subscription as canceled and expires it. This also revokes all perks linked to the subscription. + * @param input - The input for the subscription revocation + * @returns The revoked subscription + */ + revokeSubscription: (input: { + subscriptionId: string; + revokedAt: Date; + revocationReason: string | null; + }) => + Effect.gen(function* () { + const subscriptionRepository = yield* SubscriptionRepository; + const perkGrantService = yield* PerkGrantService; + + const subscription = + yield* subscriptionRepository.getSubscriptionById( + input.subscriptionId + ); + + if (!subscription) { + return yield* Effect.fail( + new SubscriptionNotFoundError({ + message: `Subscription with id ${input.subscriptionId} not found` + }) + ); + } + + yield* subscriptionRepository.updateSubscription({ + id: input.subscriptionId, + status: SubscriptionStatus.Canceled, + expiresAt: input.revokedAt, + cancellationReason: input.revocationReason, + updatedAt: new Date() + }); + + yield* perkGrantService.syncUnlockedPerks(subscription.customerId); + }) + }; + }) + } ) {} /** @@ -342,41 +343,44 @@ export class PaymentProviderCoreService extends Effect.Service - Effect.gen(function* () { - const subscriptionRepository = yield* SubscriptionRepository; - const [ - subscriptionWithSameStoreSubscriptionId, - subscriptionWithSameTransactionId, - ] = yield* Effect.all([ - subscriptionRepository.getSubscriptionByStoreSubscriptionId({ - storeSubscriptionId, - projectId, - }), - subscriptionRepository.getSubscriptionByInitialTransactionId({ - initialTransactionId: transactionId, - projectId, - }), - ], { - concurrency: "unbounded" - }); - - if (subscriptionWithSameTransactionId) { - return yield* Effect.fail( - new SubscriptionWithSameInitialTransactionIdAlreadyExistsError({ - message: `Subscription with initial transaction id ${transactionId} already exists`, - }) - ); - } - - if (subscriptionWithSameStoreSubscriptionId) { - return yield* Effect.fail( - new SubscriptionWithSameStoreSubscriptionIdAlreadyExistsError({ - message: `Subscription with store subscription id ${storeSubscriptionId} already exists`, - }) - ); - } - }); + Effect.gen(function* () { + const subscriptionRepository = yield* SubscriptionRepository; + const [ + subscriptionWithSameStoreSubscriptionId, + subscriptionWithSameTransactionId + ] = yield* Effect.all( + [ + subscriptionRepository.getSubscriptionByStoreSubscriptionId({ + storeSubscriptionId, + projectId + }), + subscriptionRepository.getSubscriptionByInitialTransactionId({ + initialTransactionId: transactionId, + projectId + }) + ], + { + concurrency: 'unbounded' + } + ); + + if (subscriptionWithSameTransactionId) { + return yield* Effect.fail( + new SubscriptionWithSameInitialTransactionIdAlreadyExistsError({ + message: `Subscription with initial transaction id ${transactionId} already exists` + }) + ); + } + + if (subscriptionWithSameStoreSubscriptionId) { + return yield* Effect.fail( + new SubscriptionWithSameStoreSubscriptionIdAlreadyExistsError({ + message: `Subscription with store subscription id ${storeSubscriptionId} already exists` + }) + ); + } + }); diff --git a/apps/web/lib/services/payment-provider.service.ts b/apps/web/lib/services/payment-provider.service.ts index 9e2c45f6a..e5ef0fc7e 100644 --- a/apps/web/lib/services/payment-provider.service.ts +++ b/apps/web/lib/services/payment-provider.service.ts @@ -1,320 +1,324 @@ -import { Data, Effect } from "effect"; -import { PaymentProviderRepository } from "../repositories/payment-provider.repository"; -import { AuthSession } from "@/lib/services/auth.service"; -import { checkProjectPermission } from "@/lib/effect/permissions"; -import { NotFoundError } from "@/lib/effect/errors"; -import { generateId } from "@/lib/id/generate"; -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; +import { Data, Effect } from 'effect'; +import { NotFoundError } from '@/lib/effect/errors'; +import { checkProjectPermission } from '@/lib/effect/permissions'; +import { generateId } from '@/lib/id/generate'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; +import { AuthSession } from '@/lib/services/auth.service'; +import { PaymentProviderConfigurationRepository } from '../repositories/payment-provider.repository'; export class PaymentProviderNotFoundError extends Data.TaggedError( - "PaymentProviderNotFoundError" + 'PaymentProviderNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class PaymentProviderAlreadyExistsError extends Data.TaggedError( - "PaymentProviderAlreadyExistsError" + 'PaymentProviderAlreadyExistsError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class PaymentProviderConfigurationNotFound extends Data.TaggedError( - "PaymentProviderConfigurationNotFound" + 'PaymentProviderConfigurationNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class PaymentProviderKeyUnavailableError extends Data.TaggedError( - "PaymentProviderKeyUnavailableError" + 'PaymentProviderKeyUnavailableError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} -export class ValidationError extends Data.TaggedError("ValidationError")<{ - readonly cause?: unknown; - readonly message: string; +export class ValidationError extends Data.TaggedError('ValidationError')<{ + readonly cause?: unknown; + readonly message: string; }> {} export class PaymentProviderService extends Effect.Service()( - "PaymentProviderService", - { - dependencies: [PaymentProviderRepository.Default], - effect: Effect.gen(function* () { - const paymentProviderRepository = yield* PaymentProviderRepository; - return { - createPaymentProviderConfiguration: (input: { - projectId: string; - providerId: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const paymentProviderRepository = yield* PaymentProviderRepository; - - // SECURITY: Authorization check - yield* checkProjectPermission( - input.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to create payment provider configurations for project ${input.projectId}` - ); - - // Find the payment provider - const provider = paymentProviders.find( - (p) => p.getId() === input.providerId - ); - if (!provider) { - return yield* Effect.fail( - new PaymentProviderNotFoundError({ - message: `Provider ${input.providerId} not found`, - }) - ); - } - - const canHaveMultipleConfigurations = - provider.getType() === "native"; - - // Check if configuration already exists for non-native providers - if (!canHaveMultipleConfigurations) { - const existingConfiguration = - yield* paymentProviderRepository.getExistingPaymentProviderConfigurationByProviderId( - { - projectId: input.projectId, - providerId: input.providerId, - } - ); - - if (existingConfiguration) { - return yield* Effect.fail( - new PaymentProviderAlreadyExistsError({ - message: `Provider ${input.providerId} can only have one configuration`, - }) - ); - } - } - - const id = generateId("paymentProviderConfiguration"); - - const newConfiguration = { - id, - configuration: provider.getDefaultGlobalConfiguration(), - enabled: provider.getIsConfigurable() ? false : true, - name: provider.getTitle(), - providerId: input.providerId, - projectId: input.projectId, - paymentProviderKey: "empty", - }; - - yield* paymentProviderRepository.createPaymentProviderConfiguration( - newConfiguration - ); - yield* Effect.log( - `Created payment provider configuration ${id} for project ${input.projectId}` - ); - - return yield* Effect.succeed({ - id, - }); - }), - - getPaymentProviderConfigurations: (projectId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - - // SECURITY: Authorization check - yield* checkProjectPermission( - projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access payment provider configurations for project ${projectId}` - ); - - return yield* paymentProviderRepository.getPaymentProviderConfigurations( - projectId - ); - }), - - getPaymentProviderConfigurationById: (id: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const configuration = - yield* paymentProviderRepository.getPaymentProviderConfigurationById( - id - ); - - if (!configuration) { - return yield* Effect.fail( - new NotFoundError({ - message: "Payment provider configuration not found", - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - configuration.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access payment provider configuration ${id} for project ${configuration.projectId}` - ); - - return configuration; - }), - - updatePaymentProviderConfiguration: (input: { - id: string; - enabled: boolean; - name?: string; - configuration: Record; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const paymentProviderRepository = yield* PaymentProviderRepository; - - // Get existing configuration - const existingConfiguration = - yield* paymentProviderRepository.getPaymentProviderConfigurationById( - input.id - ); - if (!existingConfiguration) { - return yield* Effect.fail( - new PaymentProviderConfigurationNotFound({ - message: "Payment provider configuration not found", - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - existingConfiguration.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to update payment provider configuration ${input.id}` - ); - - // Find the payment provider - const provider = paymentProviders.find( - (p) => p.getId() === existingConfiguration.providerId - ); - if (!provider) { - return yield* Effect.fail( - new PaymentProviderNotFoundError({ - message: `Provider ${existingConfiguration.providerId} not found`, - }) - ); - } - - const requireValidation = input.enabled; - - // Validate configuration if required - let parsedConfiguration = input.configuration; - if (requireValidation) { - const configurationSchema = - provider.getGlobalConfigurationSchema(); - if (!configurationSchema) { - return yield* Effect.fail( - new ValidationError({ - message: `Provider ${provider.getId()} does not have a configuration`, - }) - ); - } - - const parseResult = yield* Effect.try({ - try: () => configurationSchema.parse(input.configuration), - catch: (error) => - new ValidationError({ - message: "Validation error", - cause: error, - }), - }); - parsedConfiguration = parseResult; - } - - // Create payment provider key and check availability if enabling - let paymentProviderKey: string | undefined; - if (input.enabled) { - paymentProviderKey = provider.createGlobalKey( - parsedConfiguration as Record - ); - const isKeyAvailable = - yield* paymentProviderRepository.checkPaymentProviderKeyAvailability( - { - key: paymentProviderKey, - providerId: provider.getId(), - projectId: existingConfiguration.projectId, - excludeId: input.id, - } - ); - - if (!isKeyAvailable) { - return yield* Effect.fail( - new PaymentProviderKeyUnavailableError({ - message: - "Payment provider with similar configuration already exists.", - }) - ); - } - } - - // Update the configuration - yield* paymentProviderRepository.updatePaymentProviderConfiguration( - { - id: input.id, - configuration: parsedConfiguration, - enabled: input.enabled, - name: input.name, - ...(paymentProviderKey && { paymentProviderKey }), - } - ); - - yield* Effect.log( - `Updated payment provider configuration ${input.id}` - ); - - return yield* Effect.succeed({ - id: input.id, - }); - }), - - deletePaymentProviderConfiguration: (input: { - paymentProviderConfigurationId: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const paymentProviderRepository = yield* PaymentProviderRepository; - - // Get the payment provider configuration - const paymentProviderConfiguration = - yield* paymentProviderRepository.getPaymentProviderConfigurationById( - input.paymentProviderConfigurationId - ); - - if (!paymentProviderConfiguration) { - return yield* Effect.fail( - new PaymentProviderConfigurationNotFound({ - message: `Payment provider configuration with id ${input.paymentProviderConfigurationId} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - paymentProviderConfiguration.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to delete payment provider configuration ${input.paymentProviderConfigurationId}` - ); - - // Soft delete the configuration - yield* paymentProviderRepository.deletePaymentProviderConfiguration( - input.paymentProviderConfigurationId - ); - - yield* Effect.log( - `Deleted payment provider configuration ${input.paymentProviderConfigurationId}` - ); - - return yield* Effect.succeed(undefined); - }), - }; - }), - } + 'PaymentProviderService', + { + dependencies: [PaymentProviderConfigurationRepository.Default], + effect: Effect.gen(function* () { + const paymentProviderRepository = + yield* PaymentProviderConfigurationRepository; + return { + createPaymentProviderConfiguration: (input: { + projectId: string; + providerId: string; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const paymentProviderRepository = + yield* PaymentProviderConfigurationRepository; + + // SECURITY: Authorization check + yield* checkProjectPermission( + input.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to create payment provider configurations for project ${input.projectId}` + ); + + // Find the payment provider + const provider = paymentProviders.find( + (p) => p.getId() === input.providerId + ); + if (!provider) { + return yield* Effect.fail( + new PaymentProviderNotFoundError({ + message: `Provider ${input.providerId} not found` + }) + ); + } + + const canHaveMultipleConfigurations = + provider.getType() === 'native'; + + // Check if configuration already exists for non-native providers + if (!canHaveMultipleConfigurations) { + const existingConfiguration = + yield* paymentProviderRepository.getExistingPaymentProviderConfigurationByProviderId( + { + projectId: input.projectId, + providerId: input.providerId + } + ); + + if (existingConfiguration) { + return yield* Effect.fail( + new PaymentProviderAlreadyExistsError({ + message: `Provider ${input.providerId} can only have one configuration` + }) + ); + } + } + + const id = generateId('paymentProviderConfiguration'); + + const newConfiguration = { + id, + configuration: provider.getDefaultGlobalConfiguration(), + enabled: !provider.getIsConfigurable(), + name: provider.getTitle(), + providerId: input.providerId, + projectId: input.projectId, + paymentProviderKey: 'empty' + }; + + yield* paymentProviderRepository.createPaymentProviderConfiguration( + newConfiguration + ); + yield* Effect.log( + `Created payment provider configuration ${id} for project ${input.projectId}` + ); + + return yield* Effect.succeed({ + id + }); + }), + + getPaymentProviderConfigurations: (projectId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + + // SECURITY: Authorization check + yield* checkProjectPermission( + projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access payment provider configurations for project ${projectId}` + ); + + return yield* paymentProviderRepository.getPaymentProviderConfigurations( + projectId + ); + }), + + getPaymentProviderConfigurationById: (id: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const configuration = + yield* paymentProviderRepository.getPaymentProviderConfigurationById( + id + ); + + if (!configuration) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Payment provider configuration not found' + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + configuration.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access payment provider configuration ${id} for project ${configuration.projectId}` + ); + + return configuration; + }), + + updatePaymentProviderConfiguration: (input: { + id: string; + enabled: boolean; + name?: string; + configuration: Record; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const paymentProviderRepository = + yield* PaymentProviderConfigurationRepository; + + // Get existing configuration + const existingConfiguration = + yield* paymentProviderRepository.getPaymentProviderConfigurationById( + input.id + ); + if (!existingConfiguration) { + return yield* Effect.fail( + new PaymentProviderConfigurationNotFound({ + message: 'Payment provider configuration not found' + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + existingConfiguration.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to update payment provider configuration ${input.id}` + ); + + // Find the payment provider + const provider = paymentProviders.find( + (p) => p.getId() === existingConfiguration.providerId + ); + if (!provider) { + return yield* Effect.fail( + new PaymentProviderNotFoundError({ + message: `Provider ${existingConfiguration.providerId} not found` + }) + ); + } + + const requireValidation = input.enabled; + + // Validate configuration if required + let parsedConfiguration = input.configuration; + if (requireValidation) { + const configurationSchema = + provider.getGlobalConfigurationSchema(); + if (!configurationSchema) { + return yield* Effect.fail( + new ValidationError({ + message: `Provider ${provider.getId()} does not have a configuration` + }) + ); + } + + const parseResult = yield* Effect.try({ + try: () => configurationSchema.parse(input.configuration), + catch: (error) => + new ValidationError({ + message: 'Validation error', + cause: error + }) + }); + parsedConfiguration = parseResult; + } + + // Create payment provider key and check availability if enabling + let paymentProviderKey: string | undefined; + if (input.enabled) { + paymentProviderKey = provider.createGlobalKey( + parsedConfiguration as Record + ); + const isKeyAvailable = + yield* paymentProviderRepository.checkPaymentProviderKeyAvailability( + { + key: paymentProviderKey, + providerId: provider.getId(), + projectId: existingConfiguration.projectId, + excludeId: input.id + } + ); + + if (!isKeyAvailable) { + return yield* Effect.fail( + new PaymentProviderKeyUnavailableError({ + message: + 'Payment provider with similar configuration already exists.' + }) + ); + } + } + + // Update the configuration + yield* paymentProviderRepository.updatePaymentProviderConfiguration( + { + id: input.id, + configuration: parsedConfiguration, + enabled: input.enabled, + name: input.name, + ...(paymentProviderKey && { paymentProviderKey }) + } + ); + + yield* Effect.log( + `Updated payment provider configuration ${input.id}` + ); + + return yield* Effect.succeed({ + id: input.id + }); + }), + + deletePaymentProviderConfiguration: (input: { + paymentProviderConfigurationId: string; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const paymentProviderRepository = + yield* PaymentProviderConfigurationRepository; + + // Get the payment provider configuration + const paymentProviderConfiguration = + yield* paymentProviderRepository.getPaymentProviderConfigurationById( + input.paymentProviderConfigurationId + ); + + if (!paymentProviderConfiguration) { + return yield* Effect.fail( + new PaymentProviderConfigurationNotFound({ + message: `Payment provider configuration with id ${input.paymentProviderConfigurationId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + paymentProviderConfiguration.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to delete payment provider configuration ${input.paymentProviderConfigurationId}` + ); + + // Soft delete the configuration + yield* paymentProviderRepository.deletePaymentProviderConfiguration( + input.paymentProviderConfigurationId + ); + + yield* Effect.log( + `Deleted payment provider configuration ${input.paymentProviderConfigurationId}` + ); + + return yield* Effect.succeed(undefined); + }) + }; + }) + } ) {} diff --git a/apps/web/lib/services/paywall-location.service.ts b/apps/web/lib/services/paywall-location.service.ts index b71057172..53bca5a3d 100644 --- a/apps/web/lib/services/paywall-location.service.ts +++ b/apps/web/lib/services/paywall-location.service.ts @@ -1,182 +1,182 @@ -import { Data, Effect } from "effect"; -import { PaywallLocationRepository } from "../repositories/paywall-location.repository"; -import { AuthSession } from "@/lib/services/auth.service"; -import { Environment } from "@/lib/services/environment.service"; -import { checkProjectPermission } from "@/lib/effect/permissions"; -import { PaywallRepository } from "../repositories/paywall.repository"; -import { generateId } from "@/lib/id/generate"; +import { Data, Effect } from 'effect'; +import { checkProjectPermission } from '@/lib/effect/permissions'; +import { generateId } from '@/lib/id/generate'; +import { AuthSession } from '@/lib/services/auth.service'; +import { Environment } from '@/lib/services/environment.service'; +import { PaywallRepository } from '../repositories/paywall.repository'; +import { PaywallLocationRepository } from '../repositories/paywall-location.repository'; export class SlugAlreadyExistsError extends Data.TaggedError( - "SlugAlreadyExistsError", + 'SlugAlreadyExistsError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class DefaultPaywallNotFoundError extends Data.TaggedError( - "DefaultPaywallNotFoundError", + 'DefaultPaywallNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class PaywallLocationNotFound extends Data.TaggedError( - "PaywallLocationNotFound", + 'PaywallLocationNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class PaywallLocationService extends Effect.Service()( - "PaywallLocationService", - { - dependencies: [PaywallLocationRepository.Default], - effect: Effect.gen(function* () { - const paywallLocationRepository = yield* PaywallLocationRepository; - return { - createPaywallLocation: (input: { - projectId: string; - name: string; - slug: string; - defaultPaywallId: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - const paywallLocationRepository = yield* PaywallLocationRepository; - const paywallRepository = yield* PaywallRepository; - - // SECURITY: Authorization check - yield* checkProjectPermission( - input.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to create paywall locations for project ${input.projectId}`, - ); - - const paywallLocation = - yield* paywallLocationRepository.getPaywallLocationBySlug({ - slug: input.slug, - projectId: input.projectId, - environment: environment, - }); - if (paywallLocation) { - return yield* Effect.fail( - new SlugAlreadyExistsError({ - message: - "Paywall location with this slug already exists. Please choose a different slug.", - }), - ); - } - - const defaultPaywall = yield* paywallRepository.getPaywallById( - input.defaultPaywallId, - ); - if (!defaultPaywall) { - return yield* Effect.fail( - new DefaultPaywallNotFoundError({ - message: "Default paywall not found", - }), - ); - } - - if (defaultPaywall.projectId !== input.projectId) { - return yield* Effect.fail( - new DefaultPaywallNotFoundError({ - message: "Default paywall not found", - }), - ); - } - - const newPaywallLocation = { - id: generateId("paywallLocation"), - slug: input.slug, - projectId: input.projectId, - name: input.name, - environment: environment, - defaultPaywallId: defaultPaywall.id, - }; - - yield* paywallLocationRepository.createPaywallLocation( - newPaywallLocation, - ); - yield* Effect.log( - `Created paywall location ${newPaywallLocation.id} for project ${input.projectId}`, - ); - - // TODO: Adding a perk should unlock it for existing users? - - return yield* Effect.succeed({ - id: newPaywallLocation.id, - }); - }), - - getPaywallLocations: (projectId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - - // SECURITY: Authorization check - yield* checkProjectPermission( - projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access paywall locations for project ${projectId}`, - ); - - return yield* paywallLocationRepository.getPaywallLocations({ - projectId, - environment, - }); - }), - - getPaywallLocationById: (id: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const paywallLocation = - yield* paywallLocationRepository.getPaywallLocationById(id); - if (!paywallLocation) { - return yield* Effect.fail( - new PaywallLocationNotFound({ - message: "Paywall location not found", - }), - ); - } - // SECURITY: Authorization check - yield* checkProjectPermission( - paywallLocation.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access paywall location ${id} for project ${paywallLocation.projectId}`, - ); - return paywallLocation; - }), - - deletePaywallLocation: (input: { paywallLocationId: string }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const paywallLocationRepository = yield* PaywallLocationRepository; - const paywallLocation = - yield* paywallLocationRepository.getPaywallLocationById( - input.paywallLocationId, - ); - if (!paywallLocation) { - return yield* Effect.fail( - new PaywallLocationNotFound({ - message: `Paywall location with id ${input.paywallLocationId} not found`, - }), - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - paywallLocation.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to delete paywall location ${input.paywallLocationId}`, - ); - - yield* paywallLocationRepository.deletePaywallLocation( - input.paywallLocationId, - ); - }), - }; - }), - }, + 'PaywallLocationService', + { + dependencies: [PaywallLocationRepository.Default], + effect: Effect.gen(function* () { + const paywallLocationRepository = yield* PaywallLocationRepository; + return { + createPaywallLocation: (input: { + projectId: string; + name: string; + slug: string; + defaultPaywallId: string; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + const paywallLocationRepository = yield* PaywallLocationRepository; + const paywallRepository = yield* PaywallRepository; + + // SECURITY: Authorization check + yield* checkProjectPermission( + input.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to create paywall locations for project ${input.projectId}` + ); + + const paywallLocation = + yield* paywallLocationRepository.getPaywallLocationBySlug({ + slug: input.slug, + projectId: input.projectId, + environment + }); + if (paywallLocation) { + return yield* Effect.fail( + new SlugAlreadyExistsError({ + message: + 'Paywall location with this slug already exists. Please choose a different slug.' + }) + ); + } + + const defaultPaywall = yield* paywallRepository.getPaywallById( + input.defaultPaywallId + ); + if (!defaultPaywall) { + return yield* Effect.fail( + new DefaultPaywallNotFoundError({ + message: 'Default paywall not found' + }) + ); + } + + if (defaultPaywall.projectId !== input.projectId) { + return yield* Effect.fail( + new DefaultPaywallNotFoundError({ + message: 'Default paywall not found' + }) + ); + } + + const newPaywallLocation = { + id: generateId('paywallLocation'), + slug: input.slug, + projectId: input.projectId, + name: input.name, + environment, + defaultPaywallId: defaultPaywall.id + }; + + yield* paywallLocationRepository.createPaywallLocation( + newPaywallLocation + ); + yield* Effect.log( + `Created paywall location ${newPaywallLocation.id} for project ${input.projectId}` + ); + + // TODO: Adding a perk should unlock it for existing users? + + return yield* Effect.succeed({ + id: newPaywallLocation.id + }); + }), + + getPaywallLocations: (projectId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + + // SECURITY: Authorization check + yield* checkProjectPermission( + projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access paywall locations for project ${projectId}` + ); + + return yield* paywallLocationRepository.getPaywallLocations({ + projectId, + environment + }); + }), + + getPaywallLocationById: (id: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const paywallLocation = + yield* paywallLocationRepository.getPaywallLocationById(id); + if (!paywallLocation) { + return yield* Effect.fail( + new PaywallLocationNotFound({ + message: 'Paywall location not found' + }) + ); + } + // SECURITY: Authorization check + yield* checkProjectPermission( + paywallLocation.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access paywall location ${id} for project ${paywallLocation.projectId}` + ); + return paywallLocation; + }), + + deletePaywallLocation: (input: { paywallLocationId: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const paywallLocationRepository = yield* PaywallLocationRepository; + const paywallLocation = + yield* paywallLocationRepository.getPaywallLocationById( + input.paywallLocationId + ); + if (!paywallLocation) { + return yield* Effect.fail( + new PaywallLocationNotFound({ + message: `Paywall location with id ${input.paywallLocationId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + paywallLocation.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to delete paywall location ${input.paywallLocationId}` + ); + + yield* paywallLocationRepository.deletePaywallLocation( + input.paywallLocationId + ); + }) + }; + }) + } ) {} diff --git a/apps/web/lib/services/paywall.service.ts b/apps/web/lib/services/paywall.service.ts index fcc016a47..37c8eefc9 100644 --- a/apps/web/lib/services/paywall.service.ts +++ b/apps/web/lib/services/paywall.service.ts @@ -1,311 +1,313 @@ -import { Data, Effect } from "effect"; -import { PaywallRepository } from "../repositories/paywall.repository"; -import { AuthSession } from "@/lib/services/auth.service"; -import { Environment } from "@/lib/services/environment.service"; -import { checkProjectPermission } from "@/lib/effect/permissions"; -import { generateId } from "@/lib/id/generate"; -import { Db, TransactionContext } from "@/lib/effect/db"; +import { Data, Effect } from 'effect'; +import { Db, TransactionContext } from '@/lib/effect/db'; +import { checkProjectPermission } from '@/lib/effect/permissions'; +import { generateId } from '@/lib/id/generate'; +import { AuthSession } from '@/lib/services/auth.service'; +import { Environment } from '@/lib/services/environment.service'; +import { PaywallRepository } from '../repositories/paywall.repository'; export class PaywallNotFoundError extends Data.TaggedError( - "PaywallNotFoundError", + 'PaywallNotFoundError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} -export class PaywallInUseError extends Data.TaggedError("PaywallInUseError")<{ - readonly cause?: unknown; - readonly message: string; +export class PaywallInUseError extends Data.TaggedError('PaywallInUseError')<{ + readonly cause?: unknown; + readonly message: string; }> {} -export class ProductNotFound extends Data.TaggedError("ProductNotFound")<{ - readonly cause?: unknown; - readonly message: string; +export class ProductNotFound extends Data.TaggedError('ProductNotFound')<{ + readonly cause?: unknown; + readonly message: string; }> {} export class PaymentProviderConfigurationNotFound extends Data.TaggedError( - "PaymentProviderConfigurationNotFound", + 'PaymentProviderConfigurationNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class PaywallService extends Effect.Service()( - "PaywallService", - { - dependencies: [PaywallRepository.Default], - effect: Effect.gen(function* () { - const paywallRepository = yield* PaywallRepository; - return { - createPaywall: (input: { projectId: string; name: string }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - const paywallRepository = yield* PaywallRepository; - - // SECURITY: Authorization check - yield* checkProjectPermission( - input.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to create paywalls for project ${input.projectId}`, - ); - - const newPaywall = { - id: generateId("paywall"), - projectId: input.projectId, - name: input.name, - environment: environment, - }; - - yield* paywallRepository.createPaywall(newPaywall); - yield* Effect.log( - `Created paywall ${newPaywall.id} for project ${input.projectId}`, - ); - - return yield* Effect.succeed({ - id: newPaywall.id, - }); - }), - - getPaywalls: (projectId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - - // SECURITY: Authorization check - yield* checkProjectPermission( - projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access paywalls for project ${projectId}`, - ); - - return yield* paywallRepository.getPaywalls({ - projectId, - environment, - }); - }), - - getPaywallById: (id: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const paywall = yield* paywallRepository.getPaywallById(id); - if (!paywall) - return yield* Effect.fail( - new PaywallNotFoundError({ - message: `Paywall ${id} not found`, - }), - ); - - // SECURITY: Authorization check - yield* checkProjectPermission( - paywall.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access paywall ${id} for project ${paywall.projectId}`, - ); - - return paywall; - }), - getPaywallProducts: (paywallId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - - // First get the paywall to check permissions - const paywall = yield* paywallRepository.getPaywallById(paywallId); - if (!paywall) - return yield* Effect.fail( - new PaywallNotFoundError({ - message: `Paywall ${paywallId} not found`, - }), - ); - - // SECURITY: Authorization check - yield* checkProjectPermission( - paywall.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access paywall products for paywall ${paywallId} in project ${paywall.projectId}`, - ); - - return yield* paywallRepository.getPaywallProducts(paywallId); - }), - - updatePaywall: (input: { - paywallId: string; - name?: string | null; - paywallProducts: { - productId: string; - displayName: string; - enableNativePurchase: boolean; - enableWebCheckout: boolean; - webCheckoutPaymentProviderConfigurationProductId: string | null; - order: number; - }[]; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const paywallRepository = yield* PaywallRepository; - const db = yield* Db; - - // First check if paywall exists - const paywall = yield* paywallRepository.getPaywallById( - input.paywallId, - ); - if (!paywall) { - return yield* Effect.fail( - new PaywallNotFoundError({ - message: `Paywall ${input.paywallId} not found`, - }), - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - paywall.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to update paywall ${input.paywallId} for project ${paywall.projectId}`, - ); - - // Use transaction to update paywall and products - yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.gen(function* () { - // Update paywall name if provided - if (input.name) { - yield* paywallRepository.updatePaywall({ - id: paywall.id, - name: input.name, - }); - } - - // Update paywall products if provided - if (input.paywallProducts) { - // Delete existing paywall products - yield* paywallRepository.deletePaywallProducts(paywall.id); - - // Get products with configurations - const productIds = input.paywallProducts.map( - (p) => p.productId, - ); - const productsFromDb = - yield* paywallRepository.getProductsWithConfigurations( - productIds, - ); - - // Validate products and insert new paywall products - const sortedProducts = [...input.paywallProducts].sort( - (a, b) => a.order - b.order, - ); - for (const product of sortedProducts) { - const existingProduct = productsFromDb.find( - (p) => p.id === product.productId, - ); - - if (!existingProduct) { - return yield* Effect.fail( - new ProductNotFound({ - message: `Product with id ${product.productId} not found`, - }), - ); - } - - const webCheckoutPaymentProviderConfigurationProduct = - existingProduct.paymentProviderConfigurationProducts.find( - (p) => - p.id === - product.webCheckoutPaymentProviderConfigurationProductId, - ); - - if ( - product.enableWebCheckout && - !webCheckoutPaymentProviderConfigurationProduct - ) { - return yield* Effect.fail( - new PaymentProviderConfigurationNotFound({ - message: - "Web checkout payment provider product configuration does not exist", - }), - ); - } - - yield* paywallRepository.createPaywallProduct({ - id: generateId("paywallProduct"), - displayName: product.displayName, - order: product.order, - paywallId: paywall.id, - productId: existingProduct.id, - enableNativePurchase: product.enableNativePurchase, - enableWebCheckout: product.enableWebCheckout, - webCheckoutPaymentProviderConfigurationProductId: - product.webCheckoutPaymentProviderConfigurationProductId, - }); - } - } - }), - ), - ); - - yield* Effect.log(`Updated paywall ${input.paywallId}`); - - return yield* Effect.succeed(undefined); - }), - - deletePaywall: (input: { paywallId: string }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const paywallRepository = yield* PaywallRepository; - const db = yield* Db; - - // First check if paywall exists - const paywall = yield* paywallRepository.getPaywallById( - input.paywallId, - ); - if (!paywall) { - return yield* Effect.fail( - new PaywallNotFoundError({ - message: `Paywall ${input.paywallId} not found`, - }), - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - paywall.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to delete paywall ${input.paywallId} for project ${paywall.projectId}`, - ); - - // Check if paywall is being used by any paywall locations - const paywallLocationsUsingPaywall = - yield* paywallRepository.getPaywallLocationsUsingPaywall( - input.paywallId, - ); - if (paywallLocationsUsingPaywall.length > 0) { - return yield* Effect.fail( - new PaywallInUseError({ - message: - "You cannot delete this paywall, because some paywall locations are still using it. Please update the paywall locations to use a different paywall first, or delete the paywall locations.", - }), - ); - } - - // Use transaction to delete paywall products and paywall - yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.gen(function* () { - // Delete paywall products first - yield* paywallRepository.deletePaywallProducts( - input.paywallId, - ); - // Then delete the paywall - yield* paywallRepository.deletePaywall(input.paywallId); - }), - ), - ); - - yield* Effect.log(`Deleted paywall ${input.paywallId}`); - - return yield* Effect.succeed(undefined); - }), - }; - }), - }, + 'PaywallService', + { + dependencies: [PaywallRepository.Default], + effect: Effect.gen(function* () { + const paywallRepository = yield* PaywallRepository; + return { + createPaywall: (input: { projectId: string; name: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + const paywallRepository = yield* PaywallRepository; + + // SECURITY: Authorization check + yield* checkProjectPermission( + input.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to create paywalls for project ${input.projectId}` + ); + + const newPaywall = { + id: generateId('paywall'), + projectId: input.projectId, + name: input.name, + environment + }; + + yield* paywallRepository.createPaywall(newPaywall); + yield* Effect.log( + `Created paywall ${newPaywall.id} for project ${input.projectId}` + ); + + return yield* Effect.succeed({ + id: newPaywall.id + }); + }), + + getPaywalls: (projectId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + + // SECURITY: Authorization check + yield* checkProjectPermission( + projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access paywalls for project ${projectId}` + ); + + return yield* paywallRepository.getPaywalls({ + projectId, + environment + }); + }), + + getPaywallById: (id: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const paywall = yield* paywallRepository.getPaywallById(id); + if (!paywall) { + return yield* Effect.fail( + new PaywallNotFoundError({ + message: `Paywall ${id} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + paywall.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access paywall ${id} for project ${paywall.projectId}` + ); + + return paywall; + }), + getPaywallProducts: (paywallId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + + // First get the paywall to check permissions + const paywall = yield* paywallRepository.getPaywallById(paywallId); + if (!paywall) { + return yield* Effect.fail( + new PaywallNotFoundError({ + message: `Paywall ${paywallId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + paywall.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access paywall products for paywall ${paywallId} in project ${paywall.projectId}` + ); + + return yield* paywallRepository.getPaywallProducts(paywallId); + }), + + updatePaywall: (input: { + paywallId: string; + name?: string | null; + paywallProducts: { + productId: string; + displayName: string; + enableNativePurchase: boolean; + enableWebCheckout: boolean; + webCheckoutPaymentProviderConfigurationProductId: string | null; + order: number; + }[]; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const paywallRepository = yield* PaywallRepository; + const db = yield* Db; + + // First check if paywall exists + const paywall = yield* paywallRepository.getPaywallById( + input.paywallId + ); + if (!paywall) { + return yield* Effect.fail( + new PaywallNotFoundError({ + message: `Paywall ${input.paywallId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + paywall.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to update paywall ${input.paywallId} for project ${paywall.projectId}` + ); + + // Use transaction to update paywall and products + yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.gen(function* () { + // Update paywall name if provided + if (input.name) { + yield* paywallRepository.updatePaywall({ + id: paywall.id, + name: input.name + }); + } + + // Update paywall products if provided + if (input.paywallProducts) { + // Delete existing paywall products + yield* paywallRepository.deletePaywallProducts(paywall.id); + + // Get products with configurations + const productIds = input.paywallProducts.map( + (p) => p.productId + ); + const productsFromDb = + yield* paywallRepository.getProductsWithConfigurations( + productIds + ); + + // Validate products and insert new paywall products + const sortedProducts = [...input.paywallProducts].sort( + (a, b) => a.order - b.order + ); + for (const product of sortedProducts) { + const existingProduct = productsFromDb.find( + (p) => p.id === product.productId + ); + + if (!existingProduct) { + return yield* Effect.fail( + new ProductNotFound({ + message: `Product with id ${product.productId} not found` + }) + ); + } + + const webCheckoutPaymentProviderConfigurationProduct = + existingProduct.paymentProviderConfigurationProducts.find( + (p) => + p.id === + product.webCheckoutPaymentProviderConfigurationProductId + ); + + if ( + product.enableWebCheckout && + !webCheckoutPaymentProviderConfigurationProduct + ) { + return yield* Effect.fail( + new PaymentProviderConfigurationNotFound({ + message: + 'Web checkout payment provider product configuration does not exist' + }) + ); + } + + yield* paywallRepository.createPaywallProduct({ + id: generateId('paywallProduct'), + displayName: product.displayName, + order: product.order, + paywallId: paywall.id, + productId: existingProduct.id, + enableNativePurchase: product.enableNativePurchase, + enableWebCheckout: product.enableWebCheckout, + webCheckoutPaymentProviderConfigurationProductId: + product.webCheckoutPaymentProviderConfigurationProductId + }); + } + } + }) + ) + ); + + yield* Effect.log(`Updated paywall ${input.paywallId}`); + + return yield* Effect.succeed(undefined); + }), + + deletePaywall: (input: { paywallId: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const paywallRepository = yield* PaywallRepository; + const db = yield* Db; + + // First check if paywall exists + const paywall = yield* paywallRepository.getPaywallById( + input.paywallId + ); + if (!paywall) { + return yield* Effect.fail( + new PaywallNotFoundError({ + message: `Paywall ${input.paywallId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + paywall.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to delete paywall ${input.paywallId} for project ${paywall.projectId}` + ); + + // Check if paywall is being used by any paywall locations + const paywallLocationsUsingPaywall = + yield* paywallRepository.getPaywallLocationsUsingPaywall( + input.paywallId + ); + if (paywallLocationsUsingPaywall.length > 0) { + return yield* Effect.fail( + new PaywallInUseError({ + message: + 'You cannot delete this paywall, because some paywall locations are still using it. Please update the paywall locations to use a different paywall first, or delete the paywall locations.' + }) + ); + } + + // Use transaction to delete paywall products and paywall + yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.gen(function* () { + // Delete paywall products first + yield* paywallRepository.deletePaywallProducts( + input.paywallId + ); + // Then delete the paywall + yield* paywallRepository.deletePaywall(input.paywallId); + }) + ) + ); + + yield* Effect.log(`Deleted paywall ${input.paywallId}`); + + return yield* Effect.succeed(undefined); + }) + }; + }) + } ) {} diff --git a/apps/web/lib/services/perk-grant.service.ts b/apps/web/lib/services/perk-grant.service.ts index 75df23cd3..47133b3fc 100644 --- a/apps/web/lib/services/perk-grant.service.ts +++ b/apps/web/lib/services/perk-grant.service.ts @@ -1,134 +1,137 @@ -import { Db, TransactionContext } from "@/lib/effect/db"; -import { generateId } from "@/lib/id/generate"; -import { CustomerUnlockedPerkRepository } from "@/lib/repositories/customer-unlocked-perk.repository"; -import { CustomerRepository } from "@/lib/repositories/customer.repository"; -import { ProductPerkRepository } from "@/lib/repositories/product-perk.repository"; -import { SubscriptionRepository } from "@/lib/repositories/subscription.repository"; import { - CustomerUnlockedPerk, - CustomerUnlockedPerkStatus, - PaymentProviderConfigurationProduct, - ProductPerk, - Subscription, -} from "@voidhash/db"; -import { SubscriptionStatus } from "@voidhash/lib/constants"; -import { Effect } from "effect"; + type CustomerUnlockedPerk, + CustomerUnlockedPerkStatus, + type PaymentProviderConfigurationProduct, + type ProductPerk, + type Subscription +} from '@voidhash/db'; +import { SubscriptionStatus } from '@voidhash/lib/constants'; +import { Effect } from 'effect'; +import { Db, TransactionContext } from '@/lib/effect/db'; +import { generateId } from '@/lib/id/generate'; +import { CustomerRepository } from '@/lib/repositories/customer.repository'; +import { CustomerUnlockedPerkRepository } from '@/lib/repositories/customer-unlocked-perk.repository'; +import { ProductPerkRepository } from '@/lib/repositories/product-perk.repository'; +import { SubscriptionRepository } from '@/lib/repositories/subscription.repository'; type PerkOperationCreation = { - status: "create"; - perkId: string; - unlockedBySubscriptionId: string; - expiresAt: Date | null; + status: 'create'; + perkId: string; + unlockedBySubscriptionId: string; + expiresAt: Date | null; }; type PerkOperationReactivation = { - status: "reactivate"; - perkId: string; - unlockedBySubscriptionId: string; - expiresAt: Date | null; + status: 'reactivate'; + perkId: string; + unlockedBySubscriptionId: string; + expiresAt: Date | null; }; type PerkOperationExpiration = { - status: "expire"; - perkId: string; - unlockedBySubscriptionId: string; + status: 'expire'; + perkId: string; + unlockedBySubscriptionId: string; }; type PerkOperation = - | PerkOperationCreation - | PerkOperationReactivation - | PerkOperationExpiration; + | PerkOperationCreation + | PerkOperationReactivation + | PerkOperationExpiration; export class PerkGrantService extends Effect.Service()( - "PerkGrantService", - { - dependencies: [], - effect: Effect.gen(function* () { - return { - syncUnlockedPerks: (customerId: string) => - Effect.gen(function* () { - const customerRepository = yield* CustomerRepository; - const subscriptionRepository = yield* SubscriptionRepository; - const productPerkRepository = yield* ProductPerkRepository; - const customerUnlockedPerkRepository = - yield* CustomerUnlockedPerkRepository; - const db = yield* Db; - const [unlockedPerks, customersSubscriptions] = yield* Effect.all([ - customerRepository.getCustomersUnlockedPerks(customerId), - subscriptionRepository.getSubscriptionsByCustomerIdWithPaymentProviderConfigurationProduct( - customerId - ), - ]); - const unlockablePerks = - yield* productPerkRepository.getProductPerksByPaymentProviderConfigurationProductIds( - customersSubscriptions.map( - (subscription) => - subscription.paymentProviderConfigurationProductId - ) - ); + 'PerkGrantService', + { + dependencies: [], + effect: Effect.gen(function* () { + return { + syncUnlockedPerks: (customerId: string) => + Effect.gen(function* () { + const customerRepository = yield* CustomerRepository; + const subscriptionRepository = yield* SubscriptionRepository; + const productPerkRepository = yield* ProductPerkRepository; + const customerUnlockedPerkRepository = + yield* CustomerUnlockedPerkRepository; + const db = yield* Db; + const [unlockedPerks, customersSubscriptions] = yield* Effect.all([ + customerRepository.getCustomersUnlockedPerks(customerId), + subscriptionRepository.getSubscriptionsByCustomerIdWithPaymentProviderConfigurationProduct( + customerId + ) + ]); + const unlockablePerks = + yield* productPerkRepository.getProductPerksByPaymentProviderConfigurationProductIds( + customersSubscriptions.map( + (subscription) => + subscription.paymentProviderConfigurationProductId + ) + ); - const perksFromSubscriptionsToUnlock = - extractPerksToUnlockFromSubscriptions( - unlockedPerks, - unlockablePerks, - customersSubscriptions - ); + const perksFromSubscriptionsToUnlock = + extractPerksToUnlockFromSubscriptions( + unlockedPerks, + unlockablePerks, + customersSubscriptions + ); - const perksFromSubscriptionsToDeactivate = - extractPerksToDeactivateFromSubscriptions( - unlockedPerks, - unlockablePerks, - customersSubscriptions - ); + const perksFromSubscriptionsToDeactivate = + extractPerksToDeactivateFromSubscriptions( + unlockedPerks, + unlockablePerks, + customersSubscriptions + ); - const operations = [ - ...perksFromSubscriptionsToUnlock, - ...perksFromSubscriptionsToDeactivate, - ]; + const operations = [ + ...perksFromSubscriptionsToUnlock, + ...perksFromSubscriptionsToDeactivate + ]; - yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.all([ - ...operations.map((operation) => { - switch (operation.status) { - case "create": - return customerUnlockedPerkRepository.createCustomerUnlockedPerk( - { - id: generateId("customerUnlockedPerk"), - customerId, - perkId: operation.perkId, - unlockedBySubscriptionId: - operation.unlockedBySubscriptionId, - expiresAt: operation.expiresAt, - status: CustomerUnlockedPerkStatus.Active, - } - ); - case "reactivate": - return customerUnlockedPerkRepository.updateCustomerUnlockedPerk( - { - id: operation.perkId, - expiresAt: operation.expiresAt, - updatedAt: new Date(), - status: CustomerUnlockedPerkStatus.Active, - } - ); - case "expire": - return customerUnlockedPerkRepository.updateCustomerUnlockedPerk( - { - id: operation.perkId, - updatedAt: new Date(), - status: CustomerUnlockedPerkStatus.Expired, - } - ); - } - }), - ]) - ) - ); - }), - }; - }), - } + yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.all([ + ...operations.map((operation) => { + switch (operation.status) { + case 'create': + return customerUnlockedPerkRepository.createCustomerUnlockedPerk( + { + id: generateId('customerUnlockedPerk'), + customerId, + perkId: operation.perkId, + unlockedBySubscriptionId: + operation.unlockedBySubscriptionId, + expiresAt: operation.expiresAt, + status: CustomerUnlockedPerkStatus.Active + } + ); + case 'reactivate': + return customerUnlockedPerkRepository.updateCustomerUnlockedPerk( + { + id: operation.perkId, + expiresAt: operation.expiresAt, + updatedAt: new Date(), + status: CustomerUnlockedPerkStatus.Active + } + ); + case 'expire': + return customerUnlockedPerkRepository.updateCustomerUnlockedPerk( + { + id: operation.perkId, + updatedAt: new Date(), + status: CustomerUnlockedPerkStatus.Expired + } + ); + default: + // THIS should never happen + throw new Error('Unknown perk operation status'); + } + }) + ]) + ) + ); + }) + }; + }) + } ) {} /** @@ -140,68 +143,68 @@ export class PerkGrantService extends Effect.Service()( * @returns The perks that should be unlocked. */ const extractPerksToUnlockFromSubscriptions = ( - unlockedPerks: CustomerUnlockedPerk[], - unlockablePerks: ProductPerk[], - subscriptions: (Subscription & { - paymentProviderConfigurationProduct: PaymentProviderConfigurationProduct; - })[] + unlockedPerks: CustomerUnlockedPerk[], + unlockablePerks: ProductPerk[], + subscriptions: (Subscription & { + paymentProviderConfigurationProduct: PaymentProviderConfigurationProduct; + })[] ): (PerkOperationCreation | PerkOperationReactivation)[] => { - const subscriptionsWithRelations = enrichSubscriptionsWithPerks( - subscriptions, - unlockedPerks, - unlockablePerks - ); + const subscriptionsWithRelations = enrichSubscriptionsWithPerks( + subscriptions, + unlockedPerks, + unlockablePerks + ); - return subscriptionsWithRelations.flatMap((subscription) => { - // If the subscription is not active, we don't need to unlock any perks. - if (subscription.status !== SubscriptionStatus.Active) { - return []; - } + return subscriptionsWithRelations.flatMap((subscription) => { + // If the subscription is not active, we don't need to unlock any perks. + if (subscription.status !== SubscriptionStatus.Active) { + return []; + } - // Filter out the perks that are already unlocked. - const perksToUnlock = subscription.unlockablePerks.filter( - (unlockablePerk) => - !subscription.unlockedPerks - .filter( - (unlockedPerk) => - unlockedPerk.status === CustomerUnlockedPerkStatus.Active - ) - .some((unlockedPerk) => unlockedPerk.perkId === unlockablePerk.perkId) - ); + // Filter out the perks that are already unlocked. + const perksToUnlock = subscription.unlockablePerks.filter( + (unlockablePerk) => + !subscription.unlockedPerks + .filter( + (unlockedPerk) => + unlockedPerk.status === CustomerUnlockedPerkStatus.Active + ) + .some((unlockedPerk) => unlockedPerk.perkId === unlockablePerk.perkId) + ); - const perksToUnlockByCreation = perksToUnlock - .filter((perk) => { - const existingPerk = subscription.unlockedPerks.find( - (unlockedPerk) => unlockedPerk.perkId === perk.perkId - ); - return !existingPerk; - }) - .map((perk) => ({ - perkId: perk.perkId, - unlockedBySubscriptionId: subscription.id, - expiresAt: subscription.expiresAt, - status: "create" as const, - })); + const perksToUnlockByCreation = perksToUnlock + .filter((perk) => { + const existingPerk = subscription.unlockedPerks.find( + (unlockedPerk) => unlockedPerk.perkId === perk.perkId + ); + return !existingPerk; + }) + .map((perk) => ({ + perkId: perk.perkId, + unlockedBySubscriptionId: subscription.id, + expiresAt: subscription.expiresAt, + status: 'create' as const + })); - const perksToUnlockByReactivation = perksToUnlock - .filter((perk) => { - const existingPerk = subscription.unlockedPerks.find( - (unlockedPerk) => unlockedPerk.perkId === perk.perkId - ); - return ( - existingPerk && - existingPerk.status === CustomerUnlockedPerkStatus.Expired - ); - }) - .map((perk) => ({ - perkId: perk.perkId, - unlockedBySubscriptionId: subscription.id, - expiresAt: subscription.expiresAt, - status: "reactivate" as const, - })); + const perksToUnlockByReactivation = perksToUnlock + .filter((perk) => { + const existingPerk = subscription.unlockedPerks.find( + (unlockedPerk) => unlockedPerk.perkId === perk.perkId + ); + return ( + existingPerk && + existingPerk.status === CustomerUnlockedPerkStatus.Expired + ); + }) + .map((perk) => ({ + perkId: perk.perkId, + unlockedBySubscriptionId: subscription.id, + expiresAt: subscription.expiresAt, + status: 'reactivate' as const + })); - return [...perksToUnlockByCreation, ...perksToUnlockByReactivation]; - }); + return [...perksToUnlockByCreation, ...perksToUnlockByReactivation]; + }); }; /** @@ -212,65 +215,65 @@ const extractPerksToUnlockFromSubscriptions = ( * @returns The perks that should be deactivated. */ const extractPerksToDeactivateFromSubscriptions = ( - unlockedPerks: CustomerUnlockedPerk[], - unlockablePerks: ProductPerk[], - subscriptions: (Subscription & { - paymentProviderConfigurationProduct: PaymentProviderConfigurationProduct; - })[] + unlockedPerks: CustomerUnlockedPerk[], + unlockablePerks: ProductPerk[], + subscriptions: (Subscription & { + paymentProviderConfigurationProduct: PaymentProviderConfigurationProduct; + })[] ): PerkOperation[] => { - const subscriptionsWithRelations = enrichSubscriptionsWithPerks( - subscriptions, - unlockedPerks, - unlockablePerks - ); + const subscriptionsWithRelations = enrichSubscriptionsWithPerks( + subscriptions, + unlockedPerks, + unlockablePerks + ); - return subscriptionsWithRelations.flatMap((subscription) => { - if (subscription.status !== SubscriptionStatus.Active) { - // We will deactivate all perks for inactive subscriptions. - return subscription.unlockedPerks.map((unlockedPerk) => ({ - perkId: unlockedPerk.perkId, - unlockedBySubscriptionId: subscription.id, - status: "expire", - })); - } + return subscriptionsWithRelations.flatMap((subscription) => { + if (subscription.status !== SubscriptionStatus.Active) { + // We will deactivate all perks for inactive subscriptions. + return subscription.unlockedPerks.map((unlockedPerk) => ({ + perkId: unlockedPerk.perkId, + unlockedBySubscriptionId: subscription.id, + status: 'expire' + })); + } - // Deactive all perks that subscription is not entitled to. This can happen if the perks were removed from the product. - const perksToDeactivate = subscription.unlockedPerks - // Filter out the perks that are still entitled to. - .filter((unlockedPerk) => { - const perk = subscription.unlockablePerks.find( - (perk) => perk.perkId === unlockedPerk.perkId - ); - return !perk; - }) - .map((unlockedPerk) => ({ - ...unlockedPerk, - status: CustomerUnlockedPerkStatus.Expired, - })); + // Deactive all perks that subscription is not entitled to. This can happen if the perks were removed from the product. + const perksToDeactivate = subscription.unlockedPerks + // Filter out the perks that are still entitled to. + .filter((unlockedPerk) => { + const perk = subscription.unlockablePerks.find( + (perk) => perk.perkId === unlockedPerk.perkId + ); + return !perk; + }) + .map((unlockedPerk) => ({ + ...unlockedPerk, + status: CustomerUnlockedPerkStatus.Expired + })); - return perksToDeactivate.map((perk) => ({ - perkId: perk.perkId, - unlockedBySubscriptionId: subscription.id, - status: "expire", - })); - }); + return perksToDeactivate.map((perk) => ({ + perkId: perk.perkId, + unlockedBySubscriptionId: subscription.id, + status: 'expire' + })); + }); }; const enrichSubscriptionsWithPerks = ( - subscriptions: Subscription[], - unlockedPerks: CustomerUnlockedPerk[], - unlockablePerks: ProductPerk[] + subscriptions: Subscription[], + unlockedPerks: CustomerUnlockedPerk[], + unlockablePerks: ProductPerk[] ) => { - return subscriptions.map((subscription) => ({ - ...subscription, - unlockedPerks: unlockedPerks.filter( - (unlockedPerk) => - unlockedPerk.unlockedBySubscriptionId === subscription.id - ), - unlockablePerks: unlockablePerks.filter( - (unlockablePerk) => - unlockablePerk.productId === - subscription.paymentProviderConfigurationProductId - ), - })); + return subscriptions.map((subscription) => ({ + ...subscription, + unlockedPerks: unlockedPerks.filter( + (unlockedPerk) => + unlockedPerk.unlockedBySubscriptionId === subscription.id + ), + unlockablePerks: unlockablePerks.filter( + (unlockablePerk) => + unlockablePerk.productId === + subscription.paymentProviderConfigurationProductId + ) + })); }; diff --git a/apps/web/lib/services/perk.service.ts b/apps/web/lib/services/perk.service.ts index 2a538448b..07eb0c81f 100644 --- a/apps/web/lib/services/perk.service.ts +++ b/apps/web/lib/services/perk.service.ts @@ -1,141 +1,135 @@ -import { Data, Effect } from "effect"; -import { PerkRepository } from "../repositories/perk.repository"; -import { AuthSession } from "@/lib/services/auth.service"; -import { Environment } from "@/lib/services/environment.service"; -import { checkProjectPermission } from "@/lib/effect/permissions"; -import { NotFoundError } from "@/lib/effect/errors"; -import { generateId } from "@/lib/id/generate"; +import { Data, Effect } from 'effect'; +import { NotFoundError } from '@/lib/effect/errors'; +import { checkProjectPermission } from '@/lib/effect/permissions'; +import { generateId } from '@/lib/id/generate'; +import { AuthSession } from '@/lib/services/auth.service'; +import { Environment } from '@/lib/services/environment.service'; +import { PerkRepository } from '../repositories/perk.repository'; export class SlugAlreadyExistsError extends Data.TaggedError( - "SlugAlreadyExistsError" + 'SlugAlreadyExistsError' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} -export class PerkNotFound extends Data.TaggedError("PerkNotFound")<{ - readonly cause?: unknown; - readonly message: string; +export class PerkNotFound extends Data.TaggedError('PerkNotFound')<{ + readonly cause?: unknown; + readonly message: string; }> {} -export class PerkService extends Effect.Service()("PerkService", { - dependencies: [PerkRepository.Default], - effect: Effect.gen(function* () { - const perkRepository = yield* PerkRepository; - return { - createPerk: (input: { - projectId: string; - name: string; - slug: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - const perkRepository = yield* PerkRepository; - - // SECURITY: Authorization check - yield* checkProjectPermission( - input.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to create perks for project ${input.projectId}` - ); - - const perk = yield* perkRepository.getPerkBySlug({ - slug: input.slug, - projectId: input.projectId, - environment: environment, - }); - if (perk) { - return yield* Effect.fail( - new SlugAlreadyExistsError({ - message: - "Perk with this slug already exists. Please choose a different slug.", - }) - ); - } - - const newPerk = { - id: generateId("perk"), - slug: input.slug, - projectId: input.projectId, - name: input.name, - environment: environment, - }; - - yield* perkRepository.createPerk(newPerk); - yield* Effect.log( - `Created perk ${newPerk.id} for project ${input.projectId}` - ); - - // TODO: Adding a perk should unlock it for existing users? - - return yield* Effect.succeed({ - id: newPerk.id, - }); - }), - - getPerks: (projectId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - // SECURITY: Authorization check - yield* checkProjectPermission( - projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access perks for project ${projectId}` - ); - return yield* perkRepository.getPerks({ - projectId, - environment, - }); - }), - - getPerkById: (id: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const perk = yield* perkRepository.getPerkById(id); - if (!perk) { - return yield* Effect.fail( - new NotFoundError({ - message: "Perk not found", - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - perk.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access perk ${id} for project ${perk.projectId}` - ); - - return perk; - }), - - deletePerk: (input: { - perkId: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const perkRepository = yield* PerkRepository; - const perk = yield* perkRepository.getPerkById(input.perkId); - if (!perk) { - return yield* Effect.fail( - new PerkNotFound({ - message: `Perk with id ${input.perkId} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - perk.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to delete perk ${input.perkId}` - ); - - yield* perkRepository.deletePerk(input.perkId); - }), - }; - }), +export class PerkService extends Effect.Service()('PerkService', { + dependencies: [PerkRepository.Default], + effect: Effect.gen(function* () { + const perkRepository = yield* PerkRepository; + return { + createPerk: (input: { projectId: string; name: string; slug: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + const perkRepository = yield* PerkRepository; + + // SECURITY: Authorization check + yield* checkProjectPermission( + input.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to create perks for project ${input.projectId}` + ); + + const perk = yield* perkRepository.getPerkBySlug({ + slug: input.slug, + projectId: input.projectId, + environment + }); + if (perk) { + return yield* Effect.fail( + new SlugAlreadyExistsError({ + message: + 'Perk with this slug already exists. Please choose a different slug.' + }) + ); + } + + const newPerk = { + id: generateId('perk'), + slug: input.slug, + projectId: input.projectId, + name: input.name, + environment + }; + + yield* perkRepository.createPerk(newPerk); + yield* Effect.log( + `Created perk ${newPerk.id} for project ${input.projectId}` + ); + + // TODO: Adding a perk should unlock it for existing users? + + return yield* Effect.succeed({ + id: newPerk.id + }); + }), + + getPerks: (projectId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + // SECURITY: Authorization check + yield* checkProjectPermission( + projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access perks for project ${projectId}` + ); + return yield* perkRepository.getPerks({ + projectId, + environment + }); + }), + + getPerkById: (id: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const perk = yield* perkRepository.getPerkById(id); + if (!perk) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Perk not found' + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + perk.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access perk ${id} for project ${perk.projectId}` + ); + + return perk; + }), + + deletePerk: (input: { perkId: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const perkRepository = yield* PerkRepository; + const perk = yield* perkRepository.getPerkById(input.perkId); + if (!perk) { + return yield* Effect.fail( + new PerkNotFound({ + message: `Perk with id ${input.perkId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + perk.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to delete perk ${input.perkId}` + ); + + yield* perkRepository.deletePerk(input.perkId); + }) + }; + }) }) {} diff --git a/apps/web/lib/services/product.service.ts b/apps/web/lib/services/product.service.ts index 9346aa61b..774b7b6aa 100644 --- a/apps/web/lib/services/product.service.ts +++ b/apps/web/lib/services/product.service.ts @@ -1,851 +1,844 @@ -import { Data, Effect } from "effect"; -import { ProductRepository } from "../repositories/product.repository"; -import { AuthSession } from "@/lib/services/auth.service"; -import { Environment } from "@/lib/services/environment.service"; -import { checkProjectPermission } from "@/lib/effect/permissions"; -import { NotFoundError } from "@/lib/effect/errors"; -import { PerkRepository } from "../repositories/perk.repository"; -import { ProductPerkRepository } from "../repositories/product-perk.repository"; -import { PaymentProviderConfigurationProductRepository } from "../repositories/payment-provider-configuration-product.repository"; -import { Db, TransactionContext } from "@/lib/effect/db"; -import { paymentProviders } from "@/lib/payment-providers/payment-providers"; -import { generateId } from "@/lib/id/generate"; +import { Environment as EnvironmentEnum } from '@voidhash/lib/index'; +import { Data, Effect } from 'effect'; +import { Db, TransactionContext } from '@/lib/effect/db'; +import { NotFoundError } from '@/lib/effect/errors'; +import { checkProjectPermission } from '@/lib/effect/permissions'; +import { generateId } from '@/lib/id/generate'; import { - devCheckout, - devCheckoutPaymentProviderId, -} from "@/lib/payment-providers/dev-checkout/dev-checkout"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/index"; -import { PaymentProviderRepository } from "../repositories/payment-provider.repository"; - -export class ProductNotFound extends Data.TaggedError("ProductNotFound")<{ - readonly cause?: unknown; - readonly message: string; + devCheckout, + devCheckoutPaymentProviderId +} from '@/lib/payment-providers/dev-checkout/dev-checkout'; +import { paymentProviders } from '@/lib/payment-providers/payment-providers'; +import { AuthSession } from '@/lib/services/auth.service'; +import { Environment } from '@/lib/services/environment.service'; +import { PaymentProviderConfigurationRepository } from '../repositories/payment-provider.repository'; +import { PaymentProviderConfigurationProductRepository } from '../repositories/payment-provider-configuration-product.repository'; +import { PerkRepository } from '../repositories/perk.repository'; +import { ProductRepository } from '../repositories/product.repository'; +import { ProductPerkRepository } from '../repositories/product-perk.repository'; + +export class ProductNotFound extends Data.TaggedError('ProductNotFound')<{ + readonly cause?: unknown; + readonly message: string; }> {} export class PaymentProviderConfigurationNotFound extends Data.TaggedError( - "PaymentProviderConfigurationNotFound" + 'PaymentProviderConfigurationNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class PaymentProviderNotFound extends Data.TaggedError( - "PaymentProviderNotFound" + 'PaymentProviderNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class InvalidConfigurationError extends Data.TaggedError( - "InvalidConfiguration" + 'InvalidConfiguration' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} -export class PerkNotFound extends Data.TaggedError("PerkNotFound")<{ - readonly cause?: unknown; - readonly message: string; +export class PerkNotFound extends Data.TaggedError('PerkNotFound')<{ + readonly cause?: unknown; + readonly message: string; }> {} export class PaymentProviderConfigurationNotFoundError extends Data.TaggedError( - "PaymentProviderConfigurationNotFound" + 'PaymentProviderConfigurationNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class ProviderProductNotFound extends Data.TaggedError( - "ProviderProductNotFound" + 'ProviderProductNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class ProductService extends Effect.Service()( - "ProductService", - { - dependencies: [ - ProductRepository.Default, - ProductPerkRepository.Default, - PaymentProviderConfigurationProductRepository.Default, - ], - effect: Effect.gen(function* () { - const productRepository = yield* ProductRepository; - const productPerkRepository = yield* ProductPerkRepository; - const paymentProviderConfigurationProductRepository = - yield* PaymentProviderConfigurationProductRepository; - - return { - // Core actions - createProduct: (input: { - projectId: string; - name: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const productRepository = yield* ProductRepository; - const paymentProviderConfigurationProductRepository = - yield* PaymentProviderConfigurationProductRepository; - const environment = yield* Environment; - const db = yield* Db; - - // SECURITY: Authorization check - yield* checkProjectPermission( - input.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to create products for project ${input.projectId}` - ); - - const productId = generateId("product"); - const newProduct = { - id: productId, - projectId: input.projectId, - name: input.name, - environment, - }; - - yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.gen(function* () { - // Create the product - yield* productRepository.createProduct(newProduct); - - // For testing environment, create dev checkout configuration - if (environment === EnvironmentEnum.Testing) { - const devCheckoutConfig = yield* tx(async (dbTx) => { - return await dbTx.query.paymentProviderConfigurations.findFirst( - { - where: (configs, { eq, and }) => - and( - eq(configs.projectId, input.projectId), - eq( - configs.providerId, - devCheckoutPaymentProviderId - ) - ), - } - ); - }); - - if (!devCheckoutConfig) { - return yield* Effect.fail( - new PaymentProviderConfigurationNotFoundError({ - message: "Dev Checkout configuration not found", - }) - ); - } - - yield* paymentProviderConfigurationProductRepository.createPaymentProviderProduct( - { - id: generateId("paymentProviderProduct"), - productId: productId, - paymentProviderConfigurationId: devCheckoutConfig.id, - providerProductKey: devCheckout.createProductKey({ - productId: productId, - }), - configuration: { - productId: productId, - }, - environment, - isActive: true, - } - ); - } - }) - ) - ); - - yield* Effect.log( - `Created product ${productId} for project ${input.projectId}` - ); - - return yield* Effect.succeed({ id: productId }); - }), - - deleteProduct: (input: { - productId: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const productRepository = yield* ProductRepository; - - // Get the product to check authorization - const existingProduct = yield* productRepository.getProductById( - input.productId - ); - if (!existingProduct) { - return yield* Effect.fail( - new ProductNotFound({ - message: `Product ${input.productId} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - existingProduct.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to delete product ${input.productId} for project ${existingProduct.projectId}` - ); - - yield* productRepository.deleteProduct(input.productId); - - yield* Effect.log( - `Deleted product ${input.productId} for project ${existingProduct.projectId}` - ); - - return yield* Effect.succeed(undefined); - }), - - updateProduct: (input: { - productId: string; - name: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const productRepository = yield* ProductRepository; - - // Get the product to check authorization - const existingProduct = yield* productRepository.getProductById( - input.productId - ); - if (!existingProduct) { - return yield* Effect.fail( - new ProductNotFound({ - message: `Product ${input.productId} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - existingProduct.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to update product ${input.productId} for project ${existingProduct.projectId}` - ); - - yield* productRepository.updateProduct({ - id: input.productId, - name: input.name, - }); - - yield* Effect.log( - `Updated product ${input.productId} for project ${existingProduct.projectId}` - ); - - return yield* Effect.succeed(undefined); - }), - - createPaymentProviderProduct: (input: { - productId: string; - paymentProviderConfigurationId: string; - configuration: Record; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const productRepository = yield* ProductRepository; - const paymentProviderConfigurationProductRepository = - yield* PaymentProviderConfigurationProductRepository; - const db = yield* Db; - - // Get product and provider configuration in parallel - const [product, providerConfiguration] = yield* Effect.all([ - productRepository.getProductById(input.productId), - db.use(async (dbInstance) => { - return await dbInstance.query.paymentProviderConfigurations.findFirst( - { - where: (configs, { eq }) => - eq(configs.id, input.paymentProviderConfigurationId), - } - ); - }), - ], { - concurrency: "unbounded" - }); - - if (!product) { - return yield* Effect.fail( - new ProductNotFound({ - message: `Product ${input.productId} not found`, - }) - ); - } - - if (!providerConfiguration) { - return yield* Effect.fail( - new PaymentProviderConfigurationNotFound({ - message: `Payment provider configuration ${input.paymentProviderConfigurationId} not found`, - }) - ); - } - - // SECURITY: Authorization checks - yield* checkProjectPermission( - product.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to create payment provider products for project ${product.projectId}` - ); - - yield* checkProjectPermission( - providerConfiguration.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access payment provider configuration for project ${providerConfiguration.projectId}` - ); - - // Find the payment provider - const provider = paymentProviders.find( - (p) => p.getId() === providerConfiguration.providerId - ); - if (!provider) { - return yield* Effect.fail( - new PaymentProviderNotFound({ - message: `Payment provider ${providerConfiguration.providerId} not found`, - }) - ); - } - - // Validate configuration - const parsedConfiguration = yield* Effect.try({ - try: () => - provider - .getProductConfigurationSchema() - .parse(input.configuration), - catch: (error) => - new InvalidConfigurationError({ - message: `Invalid configuration for provider ${providerConfiguration.providerId}: ${error}`, - cause: error, - }), - }); - - return yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.gen(function* () { - // Deactivate other provider products for this product - yield* paymentProviderConfigurationProductRepository.deactivateOtherProviderProducts( - { - productId: product.id, - paymentProviderConfigurationId: - input.paymentProviderConfigurationId, - } - ); - - // Create new provider product - const newProviderProduct = { - id: generateId("paymentProviderProduct"), - productId: product.id, - paymentProviderConfigurationId: providerConfiguration.id, - providerProductKey: provider.createProductKey( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - parsedConfiguration as any - ), - environment: product.environment, - configuration: parsedConfiguration, - isActive: true, - }; - - yield* paymentProviderConfigurationProductRepository.createPaymentProviderProduct( - newProviderProduct - ); - yield* Effect.log( - `Created payment provider product ${newProviderProduct.id} for product ${product.id}` - ); - - return yield* Effect.succeed(newProviderProduct); - }) - ) - ); - }), - - updatePaymentProviderProduct: (input: { - // productId: string; - // providerProductKey: string; - // paymentProviderConfigurationId: string; - paymentProviderConfigurationProductId: string; - configuration: Record; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const productRepository = yield* ProductRepository; - const paymentProviderConfigurationProductRepository = - yield* PaymentProviderConfigurationProductRepository; - const paymentProviderConfigurationRepository = - yield* PaymentProviderRepository; - const db = yield* Db; - - const providerProduct = - yield* paymentProviderConfigurationProductRepository.getProviderProductById( - input.paymentProviderConfigurationProductId - ); - - if (!providerProduct) { - return yield* Effect.fail( - new ProviderProductNotFound({ - message: "Provider product not found", - }) - ); - } - - // Get product and provider configuration in parallel - const [product, providerConfiguration] = yield* Effect.all([ - productRepository.getProductById(providerProduct.productId), - paymentProviderConfigurationRepository.getPaymentProviderConfigurationById(providerProduct.paymentProviderConfigurationId) - ]); - - if (!product) { - return yield* Effect.fail( - new ProductNotFound({ - message: `Product ${providerProduct.productId} not found`, - }) - ); - } - - if (!providerConfiguration) { - return yield* Effect.fail( - new PaymentProviderConfigurationNotFound({ - message: `Payment provider configuration ${providerProduct.paymentProviderConfigurationId} not found`, - }) - ); - } - - // SECURITY: Authorization checks - yield* checkProjectPermission( - product.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to update payment provider products for project ${product.projectId}` - ); - - // Find the payment provider - const provider = paymentProviders.find( - (p) => - p.getId() === providerConfiguration.providerId - ); - if (!provider) { - return yield* Effect.fail( - new PaymentProviderNotFound({ - message: `Payment provider ${providerProduct.paymentProviderConfigurationId} not found`, - }) - ); - } - - // Validate configuration - const parsedConfiguration = yield* Effect.try({ - try: () => - provider - .getProductConfigurationSchema() - .parse(input.configuration), - catch: (error) => - new InvalidConfigurationError({ - message: `Invalid configuration for provider ${providerProduct.paymentProviderConfigurationId}: ${error}`, - cause: error, - }), - }); - - const newProviderProductKey = provider.createProductKey( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - parsedConfiguration as any - ); - - return yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.gen(function* () { - yield* paymentProviderConfigurationProductRepository.updatePaymentProviderProduct( - { - id: providerProduct.id, - newProviderProductKey, - configuration: parsedConfiguration, - } - ); - - yield* Effect.log( - `Updated payment provider product for product ${providerProduct.productId}` - ); - - return yield* Effect.succeed(undefined); - }) - ) - ); - }), - - setActivePaymentProviderProduct: (input: { - productId: string; - providerProductKey: string; - paymentProviderConfigurationId: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const productRepository = yield* ProductRepository; - const paymentProviderConfigurationProductRepository = - yield* PaymentProviderConfigurationProductRepository; - const db = yield* Db; - - // Get product and provider configuration in parallel - const [product, providerConfiguration] = yield* Effect.all([ - productRepository.getProductById(input.productId), - db.use(async (dbInstance) => { - return await dbInstance.query.paymentProviderConfigurations.findFirst( - { - where: (configs, { eq }) => - eq(configs.id, input.paymentProviderConfigurationId), - } - ); - }), - ], { - concurrency: "unbounded" - }); - - if (!product) { - return yield* Effect.fail( - new ProductNotFound({ - message: `Product ${input.productId} not found`, - }) - ); - } - - if (!providerConfiguration) { - return yield* Effect.fail( - new PaymentProviderConfigurationNotFound({ - message: `Payment provider configuration ${input.paymentProviderConfigurationId} not found`, - }) - ); - } - - // SECURITY: Authorization checks - yield* checkProjectPermission( - product.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to update payment provider products for project ${product.projectId}` - ); - - yield* checkProjectPermission( - providerConfiguration.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access payment provider configuration for project ${providerConfiguration.projectId}` - ); - - // Find the payment provider - const provider = paymentProviders.find( - (p) => p.getId() === providerConfiguration.providerId - ); - if (!provider) { - return yield* Effect.fail( - new PaymentProviderNotFound({ - message: `Payment provider ${providerConfiguration.providerId} not found`, - }) - ); - } - - return yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.gen(function* () { - // Deactivate other provider products for this product/configuration - yield* paymentProviderConfigurationProductRepository.deactivateOtherProviderProducts( - { - productId: input.productId, - paymentProviderConfigurationId: - input.paymentProviderConfigurationId, - excludeProviderProductKey: input.providerProductKey, - } - ); - - // Activate the selected provider product - yield* paymentProviderConfigurationProductRepository.setActivePaymentProviderProduct( - { - productId: input.productId, - paymentProviderConfigurationId: - input.paymentProviderConfigurationId, - providerProductKey: input.providerProductKey, - } - ); - - yield* Effect.log( - `Set active payment provider product ${input.providerProductKey} for product ${input.productId}` - ); - - return yield* Effect.succeed(undefined); - }) - ) - ); - }), - - // Query methods - getProducts: (projectId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - - // SECURITY: Authorization check - yield* checkProjectPermission( - projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access products for project ${projectId}` - ); - - return yield* productRepository.getProducts({ - projectId, - environment, - }); - }), - - getProductById: (id: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const product = yield* productRepository.getProductById(id); - if (!product) { - return yield* Effect.fail( - new NotFoundError({ - message: "Product not found", - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - product.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access product ${id} for project ${product.projectId}` - ); - - return product; - }), - - // Provider product methods - getProviderProductsByProductId: (productId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const product = yield* productRepository.getProductById(productId); - if (!product) { - return yield* Effect.fail( - new NotFoundError({ - message: "Product not found", - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - product.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access provider products for product ${productId}` - ); - - return yield* paymentProviderConfigurationProductRepository.getProviderProductsByProductId( - productId - ); - }), - - getProviderProductById: (id: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - - const providerProduct = - yield* paymentProviderConfigurationProductRepository.getProviderProductById( - id - ); - - if (!providerProduct) { - return yield* Effect.fail( - new NotFoundError({ - message: "Provider product not found", - }) - ); - } - - const product = yield* productRepository.getProductById( - providerProduct.productId - ); - - if (!product) { - return yield* Effect.fail( - new ProductNotFound({ - message: `Product ${providerProduct.productId} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - product.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access provider product for project ${product.projectId}` - ); - - return providerProduct; - }), - - // Product perk methods - getProductPerksByProductId: (productId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const product = yield* productRepository.getProductById(productId); - if (!product) { - return yield* Effect.fail( - new NotFoundError({ - message: "Product not found", - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - product.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to access product perks for product ${productId}` - ); - - return yield* productPerkRepository.getProductPerksByProductId( - productId - ); - }), - - createProductPerk: (input: { - productId: string; - perkId: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const productPerkRepository = yield* ProductPerkRepository; - const perkRepository = yield* PerkRepository; - - // Get product to check authorization - const product = yield* productRepository.getProductById( - input.productId - ); - if (!product) { - return yield* Effect.fail( - new ProductNotFound({ - message: `Product ${input.productId} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - product.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to create product perks for project ${product.projectId}` - ); - - // Validate perk exists (this also checks authorization) - const perk = yield* perkRepository.getPerkById(input.perkId); - if (!perk) { - return yield* Effect.fail( - new PerkNotFound({ - message: `Perk ${input.perkId} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - perk.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to create product perks in project ${product.projectId}` - ); - - const newProductPerk = { - id: generateId("productPerk"), - productId: input.productId, - perkId: input.perkId, - }; - - yield* productPerkRepository.createProductPerk(newProductPerk); - - yield* Effect.log( - `Created product perk ${newProductPerk.id} for product ${input.productId}` - ); - - return yield* Effect.succeed({ id: newProductPerk.id }); - }), - - deleteProductPerk: (input: { - productId: string; - perkId: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const productRepository = yield* ProductRepository; - const productPerkRepository = yield* ProductPerkRepository; - - const product = yield* productRepository.getProductById( - input.productId - ); - - if (!product) { - return yield* Effect.fail( - new ProductNotFound({ - message: `Product ${input.productId} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - product.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to delete product perks for product ${input.productId}` - ); - - yield* productPerkRepository.deleteProductPerk({ - productId: input.productId, - perkId: input.perkId, - }); - - yield* Effect.log( - `Deleted product perk ${input.perkId} from product ${input.productId}` - ); - - return yield* Effect.succeed(undefined); - - // TODO: Think about deleting already granted perks. - }), - - deletePaymentProviderProduct: (input: { - productId: string; - paymentProviderConfigurationId: string; - providerProductKey: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const productRepository = yield* ProductRepository; - const paymentProviderConfigurationProductRepository = - yield* PaymentProviderConfigurationProductRepository; - - // Get the product to check authorization - const product = yield* productRepository.getProductById( - input.productId - ); - if (!product) { - return yield* Effect.fail( - new ProductNotFound({ - message: `Product ${input.productId} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - product.projectId, - "project:all", - `User ${session?.user?.id} is not authorized to delete payment provider products for project ${product.projectId}` - ); - - yield* paymentProviderConfigurationProductRepository.deletePaymentProviderProduct( - { - productId: input.productId, - paymentProviderConfigurationId: - input.paymentProviderConfigurationId, - providerProductKey: input.providerProductKey, - } - ); - - yield* Effect.log( - `Deleted payment provider product for product ${input.productId}` - ); - - return yield* Effect.succeed(undefined); - }), - }; - }), - } + 'ProductService', + { + dependencies: [ + ProductRepository.Default, + ProductPerkRepository.Default, + PaymentProviderConfigurationProductRepository.Default + ], + effect: Effect.gen(function* () { + const productRepository = yield* ProductRepository; + const productPerkRepository = yield* ProductPerkRepository; + const paymentProviderConfigurationProductRepository = + yield* PaymentProviderConfigurationProductRepository; + + return { + // Core actions + createProduct: (input: { projectId: string; name: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const productRepository = yield* ProductRepository; + const paymentProviderConfigurationProductRepository = + yield* PaymentProviderConfigurationProductRepository; + const environment = yield* Environment; + const db = yield* Db; + + // SECURITY: Authorization check + yield* checkProjectPermission( + input.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to create products for project ${input.projectId}` + ); + + const productId = generateId('product'); + const newProduct = { + id: productId, + projectId: input.projectId, + name: input.name, + environment + }; + + yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.gen(function* () { + // Create the product + yield* productRepository.createProduct(newProduct); + + // For testing environment, create dev checkout configuration + if (environment === EnvironmentEnum.Testing) { + const devCheckoutConfig = yield* tx(async (dbTx) => { + return await dbTx.query.paymentProviderConfigurations.findFirst( + { + where: (configs, { eq, and }) => + and( + eq(configs.projectId, input.projectId), + eq( + configs.providerId, + devCheckoutPaymentProviderId + ) + ) + } + ); + }); + + if (!devCheckoutConfig) { + return yield* Effect.fail( + new PaymentProviderConfigurationNotFoundError({ + message: 'Dev Checkout configuration not found' + }) + ); + } + + yield* paymentProviderConfigurationProductRepository.createPaymentProviderProduct( + { + id: generateId('paymentProviderProduct'), + productId, + paymentProviderConfigurationId: devCheckoutConfig.id, + providerProductKey: devCheckout.createProductKey({ + productId + }), + configuration: { + productId + }, + environment, + isActive: true + } + ); + } + }) + ) + ); + + yield* Effect.log( + `Created product ${productId} for project ${input.projectId}` + ); + + return yield* Effect.succeed({ id: productId }); + }), + + deleteProduct: (input: { productId: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const productRepository = yield* ProductRepository; + + // Get the product to check authorization + const existingProduct = yield* productRepository.getProductById( + input.productId + ); + if (!existingProduct) { + return yield* Effect.fail( + new ProductNotFound({ + message: `Product ${input.productId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + existingProduct.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to delete product ${input.productId} for project ${existingProduct.projectId}` + ); + + yield* productRepository.deleteProduct(input.productId); + + yield* Effect.log( + `Deleted product ${input.productId} for project ${existingProduct.projectId}` + ); + + return yield* Effect.succeed(undefined); + }), + + updateProduct: (input: { productId: string; name: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const productRepository = yield* ProductRepository; + + // Get the product to check authorization + const existingProduct = yield* productRepository.getProductById( + input.productId + ); + if (!existingProduct) { + return yield* Effect.fail( + new ProductNotFound({ + message: `Product ${input.productId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + existingProduct.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to update product ${input.productId} for project ${existingProduct.projectId}` + ); + + yield* productRepository.updateProduct({ + id: input.productId, + name: input.name + }); + + yield* Effect.log( + `Updated product ${input.productId} for project ${existingProduct.projectId}` + ); + + return yield* Effect.succeed(undefined); + }), + + createPaymentProviderProduct: (input: { + productId: string; + paymentProviderConfigurationId: string; + configuration: Record; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const productRepository = yield* ProductRepository; + const paymentProviderConfigurationProductRepository = + yield* PaymentProviderConfigurationProductRepository; + const db = yield* Db; + + // Get product and provider configuration in parallel + const [product, providerConfiguration] = yield* Effect.all( + [ + productRepository.getProductById(input.productId), + db.use(async (dbInstance) => { + return await dbInstance.query.paymentProviderConfigurations.findFirst( + { + where: (configs, { eq }) => + eq(configs.id, input.paymentProviderConfigurationId) + } + ); + }) + ], + { + concurrency: 'unbounded' + } + ); + + if (!product) { + return yield* Effect.fail( + new ProductNotFound({ + message: `Product ${input.productId} not found` + }) + ); + } + + if (!providerConfiguration) { + return yield* Effect.fail( + new PaymentProviderConfigurationNotFound({ + message: `Payment provider configuration ${input.paymentProviderConfigurationId} not found` + }) + ); + } + + // SECURITY: Authorization checks + yield* checkProjectPermission( + product.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to create payment provider products for project ${product.projectId}` + ); + + yield* checkProjectPermission( + providerConfiguration.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access payment provider configuration for project ${providerConfiguration.projectId}` + ); + + // Find the payment provider + const provider = paymentProviders.find( + (p) => p.getId() === providerConfiguration.providerId + ); + if (!provider) { + return yield* Effect.fail( + new PaymentProviderNotFound({ + message: `Payment provider ${providerConfiguration.providerId} not found` + }) + ); + } + + // Validate configuration + const parsedConfiguration = yield* Effect.try({ + try: () => + provider + .getProductConfigurationSchema() + .parse(input.configuration), + catch: (error) => + new InvalidConfigurationError({ + message: `Invalid configuration for provider ${providerConfiguration.providerId}: ${error}`, + cause: error + }) + }); + + return yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.gen(function* () { + // Deactivate other provider products for this product + yield* paymentProviderConfigurationProductRepository.deactivateOtherProviderProducts( + { + productId: product.id, + paymentProviderConfigurationId: + input.paymentProviderConfigurationId + } + ); + + // Create new provider product + const newProviderProduct = { + id: generateId('paymentProviderProduct'), + productId: product.id, + paymentProviderConfigurationId: providerConfiguration.id, + providerProductKey: provider.createProductKey( + // biome-ignore lint/suspicious/noExplicitAny: it is dynamic TODO: Improve + parsedConfiguration as any + ), + environment: product.environment, + configuration: parsedConfiguration, + isActive: true + }; + + yield* paymentProviderConfigurationProductRepository.createPaymentProviderProduct( + newProviderProduct + ); + yield* Effect.log( + `Created payment provider product ${newProviderProduct.id} for product ${product.id}` + ); + + return yield* Effect.succeed(newProviderProduct); + }) + ) + ); + }), + + updatePaymentProviderProduct: (input: { + // productId: string; + // providerProductKey: string; + // paymentProviderConfigurationId: string; + paymentProviderConfigurationProductId: string; + configuration: Record; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const productRepository = yield* ProductRepository; + const paymentProviderConfigurationProductRepository = + yield* PaymentProviderConfigurationProductRepository; + const paymentProviderConfigurationRepository = + yield* PaymentProviderConfigurationRepository; + const db = yield* Db; + + const providerProduct = + yield* paymentProviderConfigurationProductRepository.getProviderProductById( + input.paymentProviderConfigurationProductId + ); + + if (!providerProduct) { + return yield* Effect.fail( + new ProviderProductNotFound({ + message: 'Provider product not found' + }) + ); + } + + // Get product and provider configuration in parallel + const [product, providerConfiguration] = yield* Effect.all([ + productRepository.getProductById(providerProduct.productId), + paymentProviderConfigurationRepository.getPaymentProviderConfigurationById( + providerProduct.paymentProviderConfigurationId + ) + ]); + + if (!product) { + return yield* Effect.fail( + new ProductNotFound({ + message: `Product ${providerProduct.productId} not found` + }) + ); + } + + if (!providerConfiguration) { + return yield* Effect.fail( + new PaymentProviderConfigurationNotFound({ + message: `Payment provider configuration ${providerProduct.paymentProviderConfigurationId} not found` + }) + ); + } + + // SECURITY: Authorization checks + yield* checkProjectPermission( + product.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to update payment provider products for project ${product.projectId}` + ); + + // Find the payment provider + const provider = paymentProviders.find( + (p) => p.getId() === providerConfiguration.providerId + ); + if (!provider) { + return yield* Effect.fail( + new PaymentProviderNotFound({ + message: `Payment provider ${providerProduct.paymentProviderConfigurationId} not found` + }) + ); + } + + // Validate configuration + const parsedConfiguration = yield* Effect.try({ + try: () => + provider + .getProductConfigurationSchema() + .parse(input.configuration), + catch: (error) => + new InvalidConfigurationError({ + message: `Invalid configuration for provider ${providerProduct.paymentProviderConfigurationId}: ${error}`, + cause: error + }) + }); + + const newProviderProductKey = provider.createProductKey( + // biome-ignore lint/suspicious/noExplicitAny: it is dynamic TODO: Improve + parsedConfiguration as any + ); + + return yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.gen(function* () { + yield* paymentProviderConfigurationProductRepository.updatePaymentProviderProduct( + { + id: providerProduct.id, + newProviderProductKey, + configuration: parsedConfiguration + } + ); + + yield* Effect.log( + `Updated payment provider product for product ${providerProduct.productId}` + ); + + return yield* Effect.succeed(undefined); + }) + ) + ); + }), + + setActivePaymentProviderProduct: (input: { + productId: string; + providerProductKey: string; + paymentProviderConfigurationId: string; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const productRepository = yield* ProductRepository; + const paymentProviderConfigurationProductRepository = + yield* PaymentProviderConfigurationProductRepository; + const db = yield* Db; + + // Get product and provider configuration in parallel + const [product, providerConfiguration] = yield* Effect.all( + [ + productRepository.getProductById(input.productId), + db.use(async (dbInstance) => { + return await dbInstance.query.paymentProviderConfigurations.findFirst( + { + where: (configs, { eq }) => + eq(configs.id, input.paymentProviderConfigurationId) + } + ); + }) + ], + { + concurrency: 'unbounded' + } + ); + + if (!product) { + return yield* Effect.fail( + new ProductNotFound({ + message: `Product ${input.productId} not found` + }) + ); + } + + if (!providerConfiguration) { + return yield* Effect.fail( + new PaymentProviderConfigurationNotFound({ + message: `Payment provider configuration ${input.paymentProviderConfigurationId} not found` + }) + ); + } + + // SECURITY: Authorization checks + yield* checkProjectPermission( + product.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to update payment provider products for project ${product.projectId}` + ); + + yield* checkProjectPermission( + providerConfiguration.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access payment provider configuration for project ${providerConfiguration.projectId}` + ); + + // Find the payment provider + const provider = paymentProviders.find( + (p) => p.getId() === providerConfiguration.providerId + ); + if (!provider) { + return yield* Effect.fail( + new PaymentProviderNotFound({ + message: `Payment provider ${providerConfiguration.providerId} not found` + }) + ); + } + + return yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.gen(function* () { + // Deactivate other provider products for this product/configuration + yield* paymentProviderConfigurationProductRepository.deactivateOtherProviderProducts( + { + productId: input.productId, + paymentProviderConfigurationId: + input.paymentProviderConfigurationId, + excludeProviderProductKey: input.providerProductKey + } + ); + + // Activate the selected provider product + yield* paymentProviderConfigurationProductRepository.setActivePaymentProviderProduct( + { + productId: input.productId, + paymentProviderConfigurationId: + input.paymentProviderConfigurationId, + providerProductKey: input.providerProductKey + } + ); + + yield* Effect.log( + `Set active payment provider product ${input.providerProductKey} for product ${input.productId}` + ); + + return yield* Effect.succeed(undefined); + }) + ) + ); + }), + + // Query methods + getProducts: (projectId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + + // SECURITY: Authorization check + yield* checkProjectPermission( + projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access products for project ${projectId}` + ); + + return yield* productRepository.getProducts({ + projectId, + environment + }); + }), + + getProductById: (id: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const product = yield* productRepository.getProductById(id); + if (!product) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Product not found' + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + product.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access product ${id} for project ${product.projectId}` + ); + + return product; + }), + + // Provider product methods + getProviderProductsByProductId: (productId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const product = yield* productRepository.getProductById(productId); + if (!product) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Product not found' + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + product.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access provider products for product ${productId}` + ); + + return yield* paymentProviderConfigurationProductRepository.getProviderProductsByProductId( + productId + ); + }), + + getProviderProductById: (id: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + + const providerProduct = + yield* paymentProviderConfigurationProductRepository.getProviderProductById( + id + ); + + if (!providerProduct) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Provider product not found' + }) + ); + } + + const product = yield* productRepository.getProductById( + providerProduct.productId + ); + + if (!product) { + return yield* Effect.fail( + new ProductNotFound({ + message: `Product ${providerProduct.productId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + product.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access provider product for project ${product.projectId}` + ); + + return providerProduct; + }), + + // Product perk methods + getProductPerksByProductId: (productId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const product = yield* productRepository.getProductById(productId); + if (!product) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Product not found' + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + product.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to access product perks for product ${productId}` + ); + + return yield* productPerkRepository.getProductPerksByProductId( + productId + ); + }), + + createProductPerk: (input: { productId: string; perkId: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const productPerkRepository = yield* ProductPerkRepository; + const perkRepository = yield* PerkRepository; + + // Get product to check authorization + const product = yield* productRepository.getProductById( + input.productId + ); + if (!product) { + return yield* Effect.fail( + new ProductNotFound({ + message: `Product ${input.productId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + product.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to create product perks for project ${product.projectId}` + ); + + // Validate perk exists (this also checks authorization) + const perk = yield* perkRepository.getPerkById(input.perkId); + if (!perk) { + return yield* Effect.fail( + new PerkNotFound({ + message: `Perk ${input.perkId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + perk.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to create product perks in project ${product.projectId}` + ); + + const newProductPerk = { + id: generateId('productPerk'), + productId: input.productId, + perkId: input.perkId + }; + + yield* productPerkRepository.createProductPerk(newProductPerk); + + yield* Effect.log( + `Created product perk ${newProductPerk.id} for product ${input.productId}` + ); + + return yield* Effect.succeed({ id: newProductPerk.id }); + }), + + deleteProductPerk: (input: { productId: string; perkId: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const productRepository = yield* ProductRepository; + const productPerkRepository = yield* ProductPerkRepository; + + const product = yield* productRepository.getProductById( + input.productId + ); + + if (!product) { + return yield* Effect.fail( + new ProductNotFound({ + message: `Product ${input.productId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + product.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to delete product perks for product ${input.productId}` + ); + + yield* productPerkRepository.deleteProductPerk({ + productId: input.productId, + perkId: input.perkId + }); + + yield* Effect.log( + `Deleted product perk ${input.perkId} from product ${input.productId}` + ); + + return yield* Effect.succeed(undefined); + + // TODO: Think about deleting already granted perks. + }), + + deletePaymentProviderProduct: (input: { + productId: string; + paymentProviderConfigurationId: string; + providerProductKey: string; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const productRepository = yield* ProductRepository; + const paymentProviderConfigurationProductRepository = + yield* PaymentProviderConfigurationProductRepository; + + // Get the product to check authorization + const product = yield* productRepository.getProductById( + input.productId + ); + if (!product) { + return yield* Effect.fail( + new ProductNotFound({ + message: `Product ${input.productId} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + product.projectId, + 'project:all', + `User ${session?.user?.id} is not authorized to delete payment provider products for project ${product.projectId}` + ); + + yield* paymentProviderConfigurationProductRepository.deletePaymentProviderProduct( + { + productId: input.productId, + paymentProviderConfigurationId: + input.paymentProviderConfigurationId, + providerProductKey: input.providerProductKey + } + ); + + yield* Effect.log( + `Deleted payment provider product for product ${input.productId}` + ); + + return yield* Effect.succeed(undefined); + }) + }; + }) + } ) {} diff --git a/apps/web/lib/services/project.service.ts b/apps/web/lib/services/project.service.ts index 584aa09c6..e20b736f3 100644 --- a/apps/web/lib/services/project.service.ts +++ b/apps/web/lib/services/project.service.ts @@ -1,325 +1,327 @@ -import { Data, Effect } from "effect"; -import { ProjectRepository } from "../repositories/project.repository"; -import { AuthSession } from "@/lib/services/auth.service"; +import { paymentProviderConfigurations } from '@voidhash/db'; import { - checkProjectPermission, - checkOrganizationPermission, -} from "@/lib/effect/permissions"; -import { OrganizationRepository } from "../repositories/organization.repository"; -import { ApiKeyRepository } from "../repositories/api-key.repository"; -import { Db, TransactionContext } from "@/lib/effect/db"; -import { UnauthorizedError } from "@/lib/effect/errors"; -import { generateId } from "@/lib/id/generate"; + createShortId, + createSlug, + Environment, + SLUG_BLACKLIST +} from '@voidhash/lib/index'; +import { Data, Effect } from 'effect'; +import { Db, TransactionContext } from '@/lib/effect/db'; +import { UnauthorizedError } from '@/lib/effect/errors'; import { - createShortId, - createSlug, - Environment, - SLUG_BLACKLIST, -} from "@voidhash/lib/index"; -import { createPublishableKey } from "../core/api-keys/effect/utils"; -import { paymentProviderConfigurations } from "@voidhash/db"; + checkOrganizationPermission, + checkProjectPermission +} from '@/lib/effect/permissions'; +import { generateId } from '@/lib/id/generate'; import { - devCheckoutPaymentProviderId, - devCheckout, -} from "@/lib/payment-providers/dev-checkout/dev-checkout"; - -export class ProjectNotFound extends Data.TaggedError("ProjectNotFound")<{ - readonly cause?: unknown; - readonly message: string; + devCheckout, + devCheckoutPaymentProviderId +} from '@/lib/payment-providers/dev-checkout/dev-checkout'; +import { AuthSession } from '@/lib/services/auth.service'; +import { createPublishableKey } from '../core/api-keys/effect/utils'; +import { ApiKeyRepository } from '../repositories/api-key.repository'; +import { OrganizationRepository } from '../repositories/organization.repository'; +import { ProjectRepository } from '../repositories/project.repository'; + +export class ProjectNotFound extends Data.TaggedError('ProjectNotFound')<{ + readonly cause?: unknown; + readonly message: string; }> {} export class ProjectService extends Effect.Service()( - "ProjectService", - { - dependencies: [ProjectRepository.Default], - effect: Effect.gen(function* () { - const projectRepository = yield* ProjectRepository; - return { - createProject: (input: { - name: string; - organizationId: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const projectRepository = yield* ProjectRepository; - const apiKeyRepository = yield* ApiKeyRepository; - const db = yield* Db; - - // SECURITY: Authorization check - yield* checkOrganizationPermission( - input.organizationId, - "organization:all", - `User ${session?.user?.id} is not authorized to create projects for organization ${input.organizationId}` - ); - - const userId = session?.user?.id; - if (!userId) { - return yield* Effect.fail( - new UnauthorizedError({ - message: "You are not authorized to create projects", - }) - ); - } - - const id = generateId("project"); - let slug = createSlug(input.name); - - if (SLUG_BLACKLIST.includes(slug)) { - slug = slug + "-" + createShortId(); - } - - const existingProject = yield* projectRepository.getProjectBySlug({ - projectSlug: slug, - organizationId: input.organizationId, - }); - - if (existingProject) { - slug = slug + "-" + createShortId(); - } - - yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.gen(function* () { - yield* projectRepository.createProject({ - id, - name: input.name, - slug, - organizationId: input.organizationId, - createdByUserId: userId, - }); - - // Create production publishable key - const productionPublishableKey = yield* createPublishableKey( - Environment.Production - ); - yield* apiKeyRepository.createApiKey({ - id: generateId("apiPublishableKey"), - projectId: id, - name: "Publishable key", - ...productionPublishableKey, - }); - - // Create testing publishable key - const testingPublishableKey = yield* createPublishableKey( - Environment.Testing - ); - yield* apiKeyRepository.createApiKey({ - id: generateId("apiPublishableKeyTesting"), - projectId: id, - name: "Publishable key", - ...testingPublishableKey, - }); - - // Create dev checkout payment provider configuration using db directly since no repository exists - const devCheckoutConfigurationId = generateId( - "paymentProviderConfiguration" - ); - yield* tx(async (dbTx) => { - await dbTx.insert(paymentProviderConfigurations).values({ - id: devCheckoutConfigurationId, - projectId: id, - name: "Dev Checkout", - providerId: devCheckoutPaymentProviderId, - paymentProviderKey: devCheckout.createGlobalKey({ - paymentProviderConfigurationId: - devCheckoutConfigurationId, - }), - enabled: true, - configuration: {}, - }); - }); - }) - ) - ); - - yield* Effect.log( - `Created project ${id} for organization ${input.organizationId}` - ); - - return yield* Effect.succeed({ - id, - slug, - }); - }), - - getProjects: (organizationId: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - - // SECURITY: Authorization check - yield* checkOrganizationPermission( - organizationId, - "organization:all", - `User ${session?.user?.id} is not authorized to access projects for organization ${organizationId}` - ); - - return yield* projectRepository.getProjects(organizationId); - }), - - getProjectById: (id: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const project = yield* projectRepository.getProjectById(id); - if (!project) return null; - - // SECURITY: Authorization check - yield* checkProjectPermission( - id, - "project:all", - `User ${session?.user?.id} is not authorized to access project ${id}` - ); - - return project; - }), - - getProjectBySlug: ({ - organizationId, - slug, - }: { - organizationId: string; - slug: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - - const project = yield* projectRepository.getProjectBySlug({ - projectSlug: slug, - organizationId, - }); - - if (!project) return null; - - // SECURITY: Authorization check for project - yield* checkProjectPermission( - project.id, - "project:all", - `User ${session?.user?.id} is not authorized to access project ${project.id}` - ); - - return project; - }), - - getProjectBySlugAndOrganizationSlug: ({ - organizationSlug, - projectSlug, - }: { - organizationSlug: string; - projectSlug: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const organizationRepository = yield* OrganizationRepository; - const organization = - yield* organizationRepository.getOrganizationBySlug( - organizationSlug - ); - if (!organization) return null; - - const project = yield* projectRepository.getProjectBySlug({ - projectSlug, - organizationId: organization.id, - }); - - if (!project) return null; - - // SECURITY: Authorization check for project - yield* checkProjectPermission( - project.id, - "project:all", - `User ${session?.user?.id} is not authorized to access project ${project.id}` - ); - - return project; - }), - - getProjectsByOrganizationSlug: (organizationSlug: string) => - Effect.gen(function* () { - const session = yield* AuthSession; - const organizationRepository = yield* OrganizationRepository; - const organization = - yield* organizationRepository.getOrganizationBySlug( - organizationSlug - ); - if (!organization) return null; - - // SECURITY: Authorization check for organization - yield* checkOrganizationPermission( - organization.id, - "organization:all", - `User ${session?.user?.id} is not authorized to access organization ${organization.id}` - ); - - return yield* projectRepository.getProjects(organization.id); - }), - - updateProject: (input: { - id: string; - name: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const projectRepository = yield* ProjectRepository; - - // First check if project exists - const project = yield* projectRepository.getProjectById(input.id); - if (!project) { - return yield* Effect.fail( - new ProjectNotFound({ - message: `Project ${input.id} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - input.id, - "project:all", - `User ${session?.user?.id} is not authorized to update project ${input.id}` - ); - - // Update the project - yield* projectRepository.updateProject({ - id: input.id, - name: input.name, - }); - - yield* Effect.log(`Updated project ${input.id}`); - - return yield* Effect.succeed(undefined); - }), - - deleteProject: (input: { - id: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const projectRepository = yield* ProjectRepository; - - // First check if project exists - const project = yield* projectRepository.getProjectById(input.id); - if (!project) { - return yield* Effect.fail( - new ProjectNotFound({ - message: `Project ${input.id} not found`, - }) - ); - } - - // SECURITY: Authorization check - yield* checkProjectPermission( - input.id, - "project:all", - `User ${session?.user?.id} is not authorized to delete project ${input.id}` - ); - - // Delete the project - yield* projectRepository.deleteProject(input.id); - - yield* Effect.log(`Deleted project ${input.id}`); - - return yield* Effect.succeed(undefined); - }), - }; - }), - } + 'ProjectService', + { + dependencies: [ProjectRepository.Default], + effect: Effect.gen(function* () { + const projectRepository = yield* ProjectRepository; + return { + createProject: (input: { name: string; organizationId: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const projectRepository = yield* ProjectRepository; + const apiKeyRepository = yield* ApiKeyRepository; + const db = yield* Db; + + // SECURITY: Authorization check + yield* checkOrganizationPermission( + input.organizationId, + 'organization:all', + `User ${session?.user?.id} is not authorized to create projects for organization ${input.organizationId}` + ); + + const userId = session?.user?.id; + if (!userId) { + return yield* Effect.fail( + new UnauthorizedError({ + message: 'You are not authorized to create projects' + }) + ); + } + + const id = generateId('project'); + let slug = createSlug(input.name); + + if (SLUG_BLACKLIST.includes(slug)) { + slug = `${slug}-${createShortId()}`; + } + + const existingProject = yield* projectRepository.getProjectBySlug({ + projectSlug: slug, + organizationId: input.organizationId + }); + + if (existingProject) { + slug = `${slug}-${createShortId()}`; + } + + yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.gen(function* () { + yield* projectRepository.createProject({ + id, + name: input.name, + slug, + organizationId: input.organizationId, + createdByUserId: userId + }); + + // Create production publishable key + const productionPublishableKey = yield* createPublishableKey( + Environment.Production + ); + yield* apiKeyRepository.createApiKey({ + id: generateId('apiPublishableKey'), + projectId: id, + name: 'Publishable key', + ...productionPublishableKey + }); + + // Create testing publishable key + const testingPublishableKey = yield* createPublishableKey( + Environment.Testing + ); + yield* apiKeyRepository.createApiKey({ + id: generateId('apiPublishableKeyTesting'), + projectId: id, + name: 'Publishable key', + ...testingPublishableKey + }); + + // Create dev checkout payment provider configuration using db directly since no repository exists + const devCheckoutConfigurationId = generateId( + 'paymentProviderConfiguration' + ); + yield* tx(async (dbTx) => { + await dbTx.insert(paymentProviderConfigurations).values({ + id: devCheckoutConfigurationId, + projectId: id, + name: 'Dev Checkout', + providerId: devCheckoutPaymentProviderId, + paymentProviderKey: devCheckout.createGlobalKey({ + paymentProviderConfigurationId: + devCheckoutConfigurationId + }), + enabled: true, + configuration: {} + }); + }); + }) + ) + ); + + yield* Effect.log( + `Created project ${id} for organization ${input.organizationId}` + ); + + return yield* Effect.succeed({ + id, + slug + }); + }), + + getProjects: (organizationId: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + + // SECURITY: Authorization check + yield* checkOrganizationPermission( + organizationId, + 'organization:all', + `User ${session?.user?.id} is not authorized to access projects for organization ${organizationId}` + ); + + return yield* projectRepository.getProjects(organizationId); + }), + + getProjectById: (id: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const project = yield* projectRepository.getProjectById(id); + if (!project) { + return null; + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + id, + 'project:all', + `User ${session?.user?.id} is not authorized to access project ${id}` + ); + + return project; + }), + + getProjectBySlug: ({ + organizationId, + slug + }: { + organizationId: string; + slug: string; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + + const project = yield* projectRepository.getProjectBySlug({ + projectSlug: slug, + organizationId + }); + + if (!project) { + return null; + } + + // SECURITY: Authorization check for project + yield* checkProjectPermission( + project.id, + 'project:all', + `User ${session?.user?.id} is not authorized to access project ${project.id}` + ); + + return project; + }), + + getProjectBySlugAndOrganizationSlug: ({ + organizationSlug, + projectSlug + }: { + organizationSlug: string; + projectSlug: string; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const organizationRepository = yield* OrganizationRepository; + const organization = + yield* organizationRepository.getOrganizationBySlug( + organizationSlug + ); + if (!organization) { + return null; + } + + const project = yield* projectRepository.getProjectBySlug({ + projectSlug, + organizationId: organization.id + }); + + if (!project) { + return null; + } + + // SECURITY: Authorization check for project + yield* checkProjectPermission( + project.id, + 'project:all', + `User ${session?.user?.id} is not authorized to access project ${project.id}` + ); + + return project; + }), + + getProjectsByOrganizationSlug: (organizationSlug: string) => + Effect.gen(function* () { + const session = yield* AuthSession; + const organizationRepository = yield* OrganizationRepository; + const organization = + yield* organizationRepository.getOrganizationBySlug( + organizationSlug + ); + if (!organization) { + return null; + } + + // SECURITY: Authorization check for organization + yield* checkOrganizationPermission( + organization.id, + 'organization:all', + `User ${session?.user?.id} is not authorized to access organization ${organization.id}` + ); + + return yield* projectRepository.getProjects(organization.id); + }), + + updateProject: (input: { id: string; name: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const projectRepository = yield* ProjectRepository; + + // First check if project exists + const project = yield* projectRepository.getProjectById(input.id); + if (!project) { + return yield* Effect.fail( + new ProjectNotFound({ + message: `Project ${input.id} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + input.id, + 'project:all', + `User ${session?.user?.id} is not authorized to update project ${input.id}` + ); + + // Update the project + yield* projectRepository.updateProject({ + id: input.id, + name: input.name + }); + + yield* Effect.log(`Updated project ${input.id}`); + + return yield* Effect.succeed(undefined); + }), + + deleteProject: (input: { id: string }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const projectRepository = yield* ProjectRepository; + + // First check if project exists + const project = yield* projectRepository.getProjectById(input.id); + if (!project) { + return yield* Effect.fail( + new ProjectNotFound({ + message: `Project ${input.id} not found` + }) + ); + } + + // SECURITY: Authorization check + yield* checkProjectPermission( + input.id, + 'project:all', + `User ${session?.user?.id} is not authorized to delete project ${input.id}` + ); + + // Delete the project + yield* projectRepository.deleteProject(input.id); + + yield* Effect.log(`Deleted project ${input.id}`); + + return yield* Effect.succeed(undefined); + }) + }; + }) + } ) {} diff --git a/apps/web/lib/services/sdk.service.ts b/apps/web/lib/services/sdk.service.ts index a66b75e8b..9687ab5ea 100644 --- a/apps/web/lib/services/sdk.service.ts +++ b/apps/web/lib/services/sdk.service.ts @@ -1,536 +1,717 @@ -import { Data, Effect } from "effect"; -import { Environment } from "@/lib/services/environment.service"; -import { AuthSession } from "@/lib/services/auth.service"; -import { Db, TransactionContext } from "@/lib/effect/db"; -import { NotFoundError, UnauthorizedError } from "@/lib/effect/errors"; -import { devCheckoutPaymentProviderId } from "@/lib/payment-providers/dev-checkout/dev-checkout"; import { - Customer, - CustomerOrigin, - CustomerType, - InsertCustomer, - PaywallProduct, -} from "@voidhash/db"; -import { CHECKOUT_DOMAIN } from "@voidhash/lib/constants"; -import { CheckoutSessionRepository } from "../repositories/checkout-session.repository"; -import { CustomerRepository } from "../repositories/customer.repository"; -import { PaymentProviderRepository } from "../repositories/payment-provider.repository"; -import { isAnonymousId } from "../core/sdk/utils"; -import { generateId } from "@/lib/id/generate"; -import { PaymentProviderConfigurationProductRepository } from "../repositories/payment-provider-configuration-product.repository"; -import { PaywallRepository } from "../repositories/paywall.repository"; + type Customer, + CustomerOrigin, + CustomerType, + type InsertCustomer, + type PaywallProduct +} from '@voidhash/db'; +import { CHECKOUT_DOMAIN } from '@voidhash/lib/constants'; import { - EnvironmentValue, - Environment as EnvironmentEnum, -} from "@voidhash/lib/index"; -import { CustomerService } from "./customer.service"; + Environment as EnvironmentEnum, + type EnvironmentValue +} from '@voidhash/lib/index'; +import { Data, Effect, pipe } from 'effect'; +import { Db, TransactionContext } from '@/lib/effect/db'; +import { NotFoundError, UnauthorizedError } from '@/lib/effect/errors'; +import { Request } from '@/lib/effect/request'; +import { generateId } from '@/lib/id/generate'; +import { devCheckoutPaymentProviderId } from '@/lib/payment-providers/dev-checkout/dev-checkout'; +import { AuthService, AuthSession } from '@/lib/services/auth.service'; +import { Environment } from '@/lib/services/environment.service'; +import { isAnonymousId } from '../core/sdk/utils'; +import { CheckoutSessionRepository } from '../repositories/checkout-session.repository'; +import { CustomerRepository } from '../repositories/customer.repository'; +import { PaymentProviderConfigurationRepository } from '../repositories/payment-provider.repository'; +import { PaymentProviderConfigurationProductRepository } from '../repositories/payment-provider-configuration-product.repository'; +import { PaywallRepository } from '../repositories/paywall.repository'; +import { PaywallLocationRepository } from '../repositories/paywall-location.repository'; +import { CustomerService } from './customer.service'; +import { parseSdkHeaders } from './helpers/sdk/load-sdk-headers'; export class PaymentProviderConfigurationNotFound extends Data.TaggedError( - "PaymentProviderConfigurationNotFound", + 'PaymentProviderConfigurationNotFound' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} -export class ProductNotFound extends Data.TaggedError("ProductNotFound")<{ - readonly cause?: unknown; - readonly message: string; +export class ProductNotFound extends Data.TaggedError('ProductNotFound')<{ + readonly cause?: unknown; + readonly message: string; }> {} -export class PaywallNotFound extends Data.TaggedError("PaywallNotFound")<{ - readonly cause?: unknown; - readonly message: string; +export class PaywallNotFound extends Data.TaggedError('PaywallNotFound')<{ + readonly cause?: unknown; + readonly message: string; }> {} export class CustomerConflictError extends Data.TaggedError( - "CustomerConflict", + 'CustomerConflict' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} export class CustomerCreationError extends Data.TaggedError( - "CustomerCreation", + 'CustomerCreation' )<{ - readonly cause?: unknown; - readonly message: string; + readonly cause?: unknown; + readonly message: string; }> {} type CreateCheckoutResponse = { - checkoutSessionId: string; - checkoutUrl: string; + checkoutSessionId: string; + checkoutUrl: string; }; type PaywallResponse = { - paywallId: string; - paywallProducts: { - paywallProductId: string; - productId: string; - price: number; - displayName: string; - nativePurchaseAvailable: boolean; - webCheckoutAvailable: boolean; - webCheckoutPaymentProviderConfigurationProductId: string | null; - }[]; + paywallId: string; + paywallProducts: { + paywallProductId: string; + productId: string; + price: number; + displayName: string; + nativePurchaseAvailable: boolean; + webCheckoutAvailable: boolean; + webCheckoutPaymentProviderConfigurationProductId: string | null; + }[]; }; -export class SdkService extends Effect.Service()("SdkService", { - dependencies: [], - effect: Effect.gen(function* () { - const checkNativePurchaseAvailability = (options: { - environment: EnvironmentValue; - paywallProduct: PaywallProduct; - }) => { - return options.paywallProduct.enableNativePurchase; - }; - - const checkWebCheckoutAvailability = (options: { - environment: EnvironmentValue; - paywallProduct: PaywallProduct; - }) => { - if (options.environment === EnvironmentEnum.Testing) { - return true; - } - - return options.paywallProduct.enableWebCheckout; - }; - - return { - createCheckout: (input: { - paymentProviderConfigurationProductId: string; - successCallbackUrl: string; - errorCallbackUrl: string; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - const paymentProviderConfigurationProductRepository = - yield* PaymentProviderConfigurationProductRepository; - const customerRepository = yield* CustomerRepository; - const checkoutSessionRepository = yield* CheckoutSessionRepository; - const paymentProviderRepository = yield* PaymentProviderRepository; - const customerService = yield* CustomerService; - const db = yield* Db; - - const appUserId = session?.customer?.appUserId; - if (!appUserId) { - return yield* Effect.fail( - new UnauthorizedError({ - message: "App user ID not found", - }), - ); - } - - const projectId = session?.projects[0]?.id; - if (!projectId) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project not found", - }), - ); - } - - // Get payment provider configuration product - const paymentProviderConfigurationProduct = - yield* paymentProviderConfigurationProductRepository.getProviderProductById( - input.paymentProviderConfigurationProductId, - ); - if (!paymentProviderConfigurationProduct) { - return yield* Effect.fail( - new ProductNotFound({ - message: "Payment provider configuration product not found", - }), - ); - } - - // Get dev checkout payment provider configuration - const devCheckoutConfiguration = - yield* paymentProviderRepository.getExistingPaymentProviderConfigurationByProviderId( - { - projectId, - providerId: devCheckoutPaymentProviderId, - }, - ); - if (!devCheckoutConfiguration) { - return yield* Effect.fail( - new PaymentProviderConfigurationNotFound({ - message: - "Dev checkout payment provider configuration not found", - }), - ); - } - - const result = yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.gen(function* () { - // Get or create customer - let customer = yield* customerRepository.getCustomerByAppUserId( - { - projectId, - appUserId, - environment, - }, - ); - - if (!customer && isAnonymousId(appUserId)) { - const newCustomer = - yield* customerService.createAnonymousCustomer({ - projectId, - appUserId, - origin: CustomerOrigin.IOS, // TODO: Make this dynamic - environment, - }); - customer = newCustomer; - } - - if (!customer) { - return yield* Effect.fail( - new NotFoundError({ - message: "Customer not found", - }), - ); - } - - // Create checkout session - const sessionId = generateId("checkoutSession"); - const sessionData = { - id: sessionId, - customerId: customer.id, - paymentProviderConfigurationProductId: - paymentProviderConfigurationProduct.id, - successCallbackUrl: input.successCallbackUrl, - errorCallbackUrl: input.errorCallbackUrl, - createdAt: new Date(), - updatedAt: new Date(), - }; - - yield* checkoutSessionRepository.createCheckoutSession( - sessionData, - ); - - return yield* Effect.succeed({ - checkoutSessionId: sessionId, - checkoutUrl: `${CHECKOUT_DOMAIN}/dev-checkout/${sessionId}`, - } satisfies CreateCheckoutResponse); - }), - ), - ); - - yield* Effect.log( - `Created checkout session ${result.checkoutSessionId} for customer ${appUserId}`, - ); - - return result; - }), - - getPaywallByLocation: (input: { - locationSlug: string; - nativePaymentProviderId?: string | null; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - const paywallRepository = yield* PaywallRepository; - - const appUserId = session?.customer?.appUserId; - if (!appUserId) { - return yield* Effect.fail( - new UnauthorizedError({ - message: "App user ID not found", - }), - ); - } - - const projectId = session?.projects[0]?.id; - if (!projectId) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project ID not found after authentication", - }), - ); - } - - // Get paywall with products by location slug - const paywallLocation = - yield* paywallRepository.getPaywallWithProductsByLocationSlug({ - locationSlug: input.locationSlug, - environment, - }); - - if (!paywallLocation?.defaultPaywall) { - return yield* Effect.fail( - new PaywallNotFound({ - message: "Paywall not found", - }), - ); - } - - const paywall = paywallLocation.defaultPaywall; - - const paywallProducts = paywall.paywallProducts.map( - (paywallProduct) => { - const product = paywallProduct.product; - - const nativePurchaseAvailable = input.nativePaymentProviderId - ? checkNativePurchaseAvailability({ - environment, - paywallProduct, - }) - : false; - - const webCheckoutAvailable = checkWebCheckoutAvailability({ - environment, - paywallProduct, - }); - - return { - paywallProductId: paywallProduct.id, - productId: product.id, - displayName: paywallProduct.displayName, - price: 100, // TODO: Get real price - nativePurchaseAvailable, - webCheckoutAvailable, - webCheckoutPaymentProviderConfigurationProductId: - webCheckoutAvailable - ? paywallProduct.webCheckoutPaymentProviderConfigurationProductId - : null, - }; - }, - ); - - const response: PaywallResponse = { - paywallId: paywall.id, - paywallProducts, - }; - - yield* Effect.log( - `Retrieved paywall ${paywall.id} for location ${input.locationSlug}`, - ); - - return yield* Effect.succeed(response); - }), - - identifyCustomer: (input: { - appUserId: string; - name: string | null; - email: string | null; - }) => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - const customerRepository = yield* CustomerRepository; - const customerService = yield* CustomerService; - const db = yield* Db; - - const projectId = session?.projects[0]?.id; - if (!projectId) { - return yield* Effect.fail( - new UnauthorizedError({ - message: "Project ID not found after authentication", - }), - ); - } - - const result = yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.gen(function* () { - const currentAppUserId = session?.customer?.appUserId; - - // Get current customer if exists - let currentCustomer: Customer | undefined; - if (currentAppUserId) { - currentCustomer = - yield* customerRepository.getCustomerByAppUserId({ - appUserId: currentAppUserId, - environment, - projectId, - }); - } - - // Get identifying as customer if exists - let identifyingAsCustomer = - yield* customerRepository.getCustomerByAppUserId({ - appUserId: input.appUserId, - environment, - projectId, - }); - - let identifyingAsCustomerId = identifyingAsCustomer?.id ?? null; - - // Can't identify already identified anonymous customer. - if ( - currentCustomer && - currentCustomer.type === CustomerType.Anonymous && - currentCustomer.parentCustomerId - ) { - const parentCustomer = - yield* customerRepository.getCustomerById( - currentCustomer.parentCustomerId, - ); - if (!parentCustomer) - return yield* Effect.die( - new Error( - "parentCustomer is null event though it should exist", - ), - ); - - if (parentCustomer.appUserId !== input.appUserId) { - return yield* Effect.fail( - new CustomerConflictError({ - message: "Anonymous customer is already identified", - }), - ); - } - - return parentCustomer; - } - - // If identifying as customer doesn't exist, create a new one - if (!identifyingAsCustomer) { - const newCustomer = { - id: generateId("customer"), - projectId, - appUserId: input.appUserId, - parentCustomerId: null, - name: input.name ?? null, - email: input.email ?? null, - origin: CustomerOrigin.IOS, // TODO: Make this dynamic - environment, - type: CustomerType.Identified, - } satisfies InsertCustomer; - - yield* customerRepository.createCustomer(newCustomer); - identifyingAsCustomerId = newCustomer.id; - - identifyingAsCustomer = { - ...newCustomer, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - parentCustomerId: null, - }; - } - - if (!identifyingAsCustomerId) { - return yield* Effect.fail( - new CustomerCreationError({ - message: "Failed to identify customer", - }), - ); - } - - // Merge customers if current customer is anonymous - if ( - currentCustomer && - currentCustomer.type === CustomerType.Anonymous - ) { - yield* customerService.mergeCustomers( - currentCustomer.id, - identifyingAsCustomerId, - ); - } - - // Get updated identified customer - const updatedCustomer = - yield* customerRepository.getCustomerByAppUserId({ - appUserId: input.appUserId, - environment, - projectId, - }); - - if (!updatedCustomer) { - return yield* Effect.fail( - new CustomerCreationError({ - message: "Failed to get customer after identification", - }), - ); - } - - return updatedCustomer; - }), - ), - ); - - yield* Effect.log( - `Identified customer ${result.id} for app user ${input.appUserId}`, - ); - - return result; - }), - - getCustomerOrCreateAnonymous: () => - Effect.gen(function* () { - const session = yield* AuthSession; - const environment = yield* Environment; - const customerRepository = yield* CustomerRepository; - const customerService = yield* CustomerService; - const db = yield* Db; - - const appUserId = session?.customer?.appUserId; - if (!appUserId) { - return yield* Effect.fail( - new UnauthorizedError({ - message: "App user ID not found", - }), - ); - } - - const projectId = session?.projects[0]?.id; - if (!projectId) { - return yield* Effect.fail( - new NotFoundError({ - message: "Project ID not found after authentication", - }), - ); - } - - const result = yield* db.transaction((tx) => - TransactionContext.provide(tx)( - Effect.gen(function* () { - // Try to get existing customer - const customer = - yield* customerRepository.getCustomerByAppUserId({ - appUserId, - environment, - projectId, - }); - - if (customer) { - // Return parent if it exists, otherwise return the customer itself - if (customer.parentCustomerId) { - const parentCustomer = - yield* customerRepository.getCustomerById( - customer.parentCustomerId, - ); - return parentCustomer; - } - return customer; - } - - // Customer not found, check if we should create anonymous customer - if (isAnonymousId(appUserId)) { - const newCustomer = - yield* customerService.createAnonymousCustomer({ - projectId, - appUserId, - origin: CustomerOrigin.IOS, // TODO: Make this dynamic - environment, - }); - return newCustomer; - } - - // Customer not found and not anonymous ID - return yield* Effect.fail( - new NotFoundError({ - message: "Customer not found", - }), - ); - }), - ), - ); - - return result; - }), - }; - }), +type CustomerAttributesParams = { + name?: string; + email?: string; +}; + +export class SdkService extends Effect.Service()('SdkService', { + dependencies: [], + effect: Effect.gen(function* () { + const isNativePurchaseAvailable = (options: { + environment: EnvironmentValue; + paywallProduct: PaywallProduct; + }) => { + return options.paywallProduct.enableNativePurchase; + }; + + const isWebCheckoutAvailable = (options: { + environment: EnvironmentValue; + paywallProduct: PaywallProduct; + }) => { + if (options.environment === EnvironmentEnum.Testing) { + return true; + } + + return options.paywallProduct.enableWebCheckout; + }; + + return { + createCheckout: (input: { + paymentProviderConfigurationProductId: string; + successCallbackUrl: string; + errorCallbackUrl: string; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + const paymentProviderConfigurationProductRepository = + yield* PaymentProviderConfigurationProductRepository; + const customerRepository = yield* CustomerRepository; + const checkoutSessionRepository = yield* CheckoutSessionRepository; + const paymentProviderRepository = + yield* PaymentProviderConfigurationRepository; + const customerService = yield* CustomerService; + const db = yield* Db; + + const appUserId = session?.customer?.appUserId; + if (!appUserId) { + return yield* Effect.fail( + new UnauthorizedError({ + message: 'App user ID not found' + }) + ); + } + + const projectId = session?.projects[0]?.id; + if (!projectId) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project not found' + }) + ); + } + + // Get payment provider configuration product + const paymentProviderConfigurationProduct = + yield* paymentProviderConfigurationProductRepository.getProviderProductById( + input.paymentProviderConfigurationProductId + ); + if (!paymentProviderConfigurationProduct) { + return yield* Effect.fail( + new ProductNotFound({ + message: 'Payment provider configuration product not found' + }) + ); + } + + // Get dev checkout payment provider configuration + const devCheckoutConfiguration = + yield* paymentProviderRepository.getExistingPaymentProviderConfigurationByProviderId( + { + projectId, + providerId: devCheckoutPaymentProviderId + } + ); + if (!devCheckoutConfiguration) { + return yield* Effect.fail( + new PaymentProviderConfigurationNotFound({ + message: 'Dev checkout payment provider configuration not found' + }) + ); + } + + const result = yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.gen(function* () { + // Get or create customer + let customer = yield* customerRepository.getCustomerByAppUserId( + { + projectId, + appUserId, + environment + } + ); + + if (!customer && isAnonymousId(appUserId)) { + const newCustomer = yield* customerService.createCustomer({ + projectId, + appUserId, + origin: CustomerOrigin.IOS, // TODO: Make this dynamic + environment + }); + customer = newCustomer; + } + + if (!customer) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Customer not found' + }) + ); + } + + // Create checkout session + const sessionId = generateId('checkoutSession'); + const sessionData = { + id: sessionId, + customerId: customer.id, + paymentProviderConfigurationProductId: + paymentProviderConfigurationProduct.id, + successCallbackUrl: input.successCallbackUrl, + errorCallbackUrl: input.errorCallbackUrl, + createdAt: new Date(), + updatedAt: new Date() + }; + + yield* checkoutSessionRepository.createCheckoutSession( + sessionData + ); + + return yield* Effect.succeed({ + checkoutSessionId: sessionId, + checkoutUrl: `${CHECKOUT_DOMAIN}/dev-checkout/${sessionId}` + } satisfies CreateCheckoutResponse); + }) + ) + ); + + yield* Effect.log( + `Created checkout session ${result.checkoutSessionId} for customer ${appUserId}` + ); + + return result; + }), + + getPaywallByLocation: (input: { + locationSlug: string; + nativePaymentProviderId?: string | null; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + const paywallRepository = yield* PaywallRepository; + + const appUserId = session?.customer?.appUserId; + if (!appUserId) { + return yield* Effect.fail( + new UnauthorizedError({ + message: 'App user ID not found' + }) + ); + } + + const projectId = session?.projects[0]?.id; + if (!projectId) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project ID not found after authentication' + }) + ); + } + + // Get paywall with products by location slug + const paywallLocation = + yield* paywallRepository.getPaywallWithProductsByLocationSlug({ + locationSlug: input.locationSlug, + environment + }); + + if (!paywallLocation?.defaultPaywall) { + return yield* Effect.fail( + new PaywallNotFound({ + message: 'Paywall not found' + }) + ); + } + + const paywall = paywallLocation.defaultPaywall; + + const paywallProducts = paywall.paywallProducts.map( + (paywallProduct) => { + const product = paywallProduct.product; + + const nativePurchaseAvailable = input.nativePaymentProviderId + ? isNativePurchaseAvailable({ + environment, + paywallProduct + }) + : false; + + const webCheckoutAvailable = isWebCheckoutAvailable({ + environment, + paywallProduct + }); + + return { + paywallProductId: paywallProduct.id, + productId: product.id, + displayName: paywallProduct.displayName, + price: 100, // TODO: Get real price + nativePurchaseAvailable, + webCheckoutAvailable, + webCheckoutPaymentProviderConfigurationProductId: + webCheckoutAvailable + ? paywallProduct.webCheckoutPaymentProviderConfigurationProductId + : null + }; + } + ); + + const response: PaywallResponse = { + paywallId: paywall.id, + paywallProducts + }; + + yield* Effect.log( + `Retrieved paywall ${paywall.id} for location ${input.locationSlug}` + ); + + return yield* Effect.succeed(response); + }), + + getConfiguration: () => + Effect.gen(function* () { + const request = yield* Request; + const environment = yield* Environment; + const authService = yield* AuthService; + const paywallRepository = yield* PaywallRepository; + const paywallLocationRepository = yield* PaywallLocationRepository; + const paymentProviderRepository = + yield* PaymentProviderConfigurationRepository; + + const headers = yield* request.getHeaders(); + const sdkHeaders = parseSdkHeaders(headers); + const projectId = yield* authService.getAuthorizedProjectId(); + // const appUserId = sdkHeaders["X-App-User-Id"]; + + const [paywalls, paywallLocations, paymentProviderConfigurations] = + yield* Effect.all( + [ + paywallRepository.getPaywallsWithProductsAndPaymentProviderConfigurations( + { + projectId, + environment + } + ), + paywallLocationRepository.getPaywallLocations({ + projectId, + environment + }), + paymentProviderRepository.getPaymentProviderConfigurations( + projectId + ) + ], + { + concurrency: 'unbounded' + } + ); + + const enabledPaymentProviderConfigurations = + paymentProviderConfigurations.filter( + (paymentProviderConfiguration) => + paymentProviderConfiguration.enabled + ); + + const placements = paywallLocations.map((paywallLocation) => { + return { + paywallId: paywallLocation.defaultPaywallId, + paywallLocationId: paywallLocation.id + }; + }); + + yield* Effect.logDebug(sdkHeaders); + + return { + paywalls, + paywallLocations, + placements, + paymentProviderConfigurations: enabledPaymentProviderConfigurations + }; + }), + + identifyCustomer: (input: { + appUserId: string; + name: string | null; + email: string | null; + }) => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + const customerRepository = yield* CustomerRepository; + const customerService = yield* CustomerService; + const db = yield* Db; + + const projectId = session?.projects[0]?.id; + if (!projectId) { + return yield* Effect.fail( + new UnauthorizedError({ + message: 'Project ID not found after authentication' + }) + ); + } + + const result = yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.gen(function* () { + const currentAppUserId = session?.customer?.appUserId; + + // Get current customer if exists + let currentCustomer: Customer | undefined; + if (currentAppUserId) { + currentCustomer = + yield* customerRepository.getCustomerByAppUserId({ + appUserId: currentAppUserId, + environment, + projectId + }); + } + + // Get identifying as customer if exists + let identifyingAsCustomer = + yield* customerRepository.getCustomerByAppUserId({ + appUserId: input.appUserId, + environment, + projectId + }); + + let identifyingAsCustomerId = identifyingAsCustomer?.id ?? null; + + // Can't identify already identified anonymous customer. + if ( + currentCustomer && + currentCustomer.type === CustomerType.Anonymous && + currentCustomer.parentCustomerId + ) { + const parentCustomer = + yield* customerRepository.getCustomerById( + currentCustomer.parentCustomerId + ); + if (!parentCustomer) { + return yield* Effect.die( + new Error( + 'parentCustomer is null event though it should exist' + ) + ); + } + + if (parentCustomer.appUserId !== input.appUserId) { + return yield* Effect.fail( + new CustomerConflictError({ + message: 'Anonymous customer is already identified' + }) + ); + } + + return parentCustomer; + } + + // If identifying as customer doesn't exist, create a new one + if (!identifyingAsCustomer) { + const newCustomer = { + id: generateId('customer'), + projectId, + appUserId: input.appUserId, + parentCustomerId: null, + name: input.name ?? null, + email: input.email ?? null, + origin: CustomerOrigin.IOS, // TODO: Make this dynamic + environment, + type: CustomerType.Identified, + additionalAttributes: {} + } satisfies InsertCustomer; + + yield* customerRepository.createCustomer(newCustomer); + identifyingAsCustomerId = newCustomer.id; + + identifyingAsCustomer = { + ...newCustomer, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + parentCustomerId: null + }; + } + + if (!identifyingAsCustomerId) { + return yield* Effect.fail( + new CustomerCreationError({ + message: 'Failed to identify customer' + }) + ); + } + + // Merge customers if current customer is anonymous + if ( + currentCustomer && + currentCustomer.type === CustomerType.Anonymous + ) { + yield* customerService.mergeCustomers( + currentCustomer.id, + identifyingAsCustomerId + ); + } + + // Get updated identified customer + const updatedCustomer = + yield* customerRepository.getCustomerByAppUserId({ + appUserId: input.appUserId, + environment, + projectId + }); + + if (!updatedCustomer) { + return yield* Effect.fail( + new CustomerCreationError({ + message: 'Failed to get customer after identification' + }) + ); + } + + return updatedCustomer; + }) + ) + ); + + yield* Effect.log( + `Identified customer ${result.id} for app user ${input.appUserId}` + ); + + return result; + }), + + syncCustomerAttributes: (input: CustomerAttributesParams) => + Effect.gen(function* () { + const request = yield* Request; + const session = yield* AuthSession; + const environment = yield* Environment; + const customerRepository = yield* CustomerRepository; + const customerService = yield* CustomerService; + + const appUserId = session?.customer?.appUserId; + if (!appUserId) { + return yield* Effect.fail( + new UnauthorizedError({ + message: 'App user ID not found' + }) + ); + } + + const projectId = session?.projects[0]?.id; + if (!projectId) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project ID not found after authentication' + }) + ); + } + + // Get or create customer + const customer = yield* pipe( + customerRepository.getCustomerByAppUserId({ + appUserId, + environment, + projectId + }), + + Effect.andThen((customer) => { + if (customer) { + return Effect.succeed(customer); + } + + return pipe( + customerService.createCustomer({ + projectId, + appUserId, + origin: CustomerOrigin.IOS, // TODO: Make this dynamic + environment + }), + + // Get customer after creation + Effect.andThen(() => + customerRepository.getCustomerByAppUserId({ + appUserId, + environment, + projectId + }) + ), + + // This is required to make the type checker happy + Effect.andThen((customer) => + customer + ? Effect.succeed(customer) + : Effect.dieMessage( + 'Customer not found after syncCustomerData. This should never happen, because we created it before retrieving it.' + ) + ) + ); + }) + ); + + const headers = yield* request.getHeaders(); + const sdkHeaders = parseSdkHeaders(headers); + + yield* Effect.log(JSON.stringify(sdkHeaders, null, 2)); + + yield* customerRepository.updateCustomer({ + id: customer.id, + name: input.name, + email: input.email, + additionalAttributes: { + ...(customer.additionalAttributes ?? {}), + platform: sdkHeaders['x-platform'], + sdk: sdkHeaders['x-sdk'], + sdkVersion: sdkHeaders['x-sdk-version'], + platformFlavor: sdkHeaders['x-platform-flavor'], + platformFlavorVersion: sdkHeaders['x-platform-flavor-version'], + platformVersion: sdkHeaders['x-platform-version'], + platformDevice: sdkHeaders['x-platform-device'], + platformBrand: sdkHeaders['x-platform-brand'], + preferredLocales: sdkHeaders['x-preferred-locales'], + clientLocale: sdkHeaders['x-client-locale'], + clientVersion: sdkHeaders['x-client-version'], + storefront: sdkHeaders['x-storefront'] + } + }); + + yield* Effect.log( + `Synced customer data ${JSON.stringify( + { + id: customer.id, + name: input.name, + email: input.email, + additionalAttributes: { + ...(customer.additionalAttributes ?? {}), + platform: sdkHeaders['x-platform'], + sdk: sdkHeaders['x-sdk'], + sdkVersion: sdkHeaders['x-sdk-version'], + platformFlavor: sdkHeaders['x-platform-flavor'], + platformFlavorVersion: + sdkHeaders['x-platform-flavor-version'], + platformVersion: sdkHeaders['x-platform-version'], + platformDevice: sdkHeaders['x-platform-device'], + platformBrand: sdkHeaders['x-platform-brand'], + preferredLocales: sdkHeaders['x-preferred-locales'], + clientLocale: sdkHeaders['x-client-locale'], + clientVersion: sdkHeaders['x-client-version'], + storefront: sdkHeaders['x-storefront'] + } + }, + null, + 2 + )} for customer ${customer.id} for app user ${appUserId}` + ); + + return customer; + }), + + getCustomer: () => + Effect.gen(function* () { + const session = yield* AuthSession; + const environment = yield* Environment; + const customerRepository = yield* CustomerRepository; + const db = yield* Db; + + const appUserId = session?.customer?.appUserId; + if (!appUserId) { + return yield* Effect.fail( + new UnauthorizedError({ + message: 'App user ID not found' + }) + ); + } + + const projectId = session?.projects[0]?.id; + if (!projectId) { + return yield* Effect.fail( + new NotFoundError({ + message: 'Project ID not found after authentication' + }) + ); + } + + const result = yield* db.transaction((tx) => + TransactionContext.provide(tx)( + Effect.gen(function* () { + // Try to get existing customer + const customer = + yield* customerRepository.getCustomerByAppUserId({ + appUserId, + environment, + projectId + }); + + if (customer) { + // Return parent if it exists, otherwise return the customer itself + if (customer.parentCustomerId) { + const parentCustomer = + yield* customerRepository.getCustomerById( + customer.parentCustomerId + ); + return parentCustomer; + } + return customer; + } + + // Customer not found and not anonymous ID + return yield* Effect.fail( + new NotFoundError({ + message: 'Customer not found' + }) + ); + }) + ) + ); + + return result; + }) + }; + }) }) {} diff --git a/apps/web/lib/services/tests/api-key.service.error.integration.test.ts b/apps/web/lib/services/tests/api-key.service.error.integration.test.ts index 40e525e13..62e195bb8 100644 --- a/apps/web/lib/services/tests/api-key.service.error.integration.test.ts +++ b/apps/web/lib/services/tests/api-key.service.error.integration.test.ts @@ -1,109 +1,109 @@ -import { describe, expect, test } from "vitest"; -import { Cause, Effect, Exit, pipe } from "effect"; -import { AuthSession } from "../auth.service"; -import { createMockEnvironment } from "../../testing/__mocks__/environment.mock"; -import { Environment } from "../environment.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/constants"; -import { ApiKeyService, ApiKeyNotFoundError } from "../api-key.service"; -import { IntegrationHarness } from "../../testing/integration-harness"; -import { createIntegrationTestRunner } from "../../effect/runtimes/integration-test"; -import { generateId } from "@/lib/id/generate"; +import { Environment as EnvironmentEnum } from '@voidhash/lib/constants'; +import { Cause, Effect, Exit, pipe } from 'effect'; +import { describe, expect, test } from 'vitest'; +import { generateId } from '@/lib/id/generate'; +import { createIntegrationTestRunner } from '../../effect/runtimes/integration-test'; +import { createMockEnvironment } from '../../testing/__mocks__/environment.mock'; +import { IntegrationHarness } from '../../testing/integration-harness'; +import { ApiKeyNotFoundError, ApiKeyService } from '../api-key.service'; +import { AuthSession } from '../auth.service'; +import { Environment } from '../environment.service'; -describe.sequential("ApiKeyService error path", () => { - test("should fail to get API key by non-existent ID", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('ApiKeyService error path', () => { + test('should fail to get API key by non-existent ID', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("apiSecretKey"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - const apiKey = yield* apiKeyService.getApiKeyById(nonExistentId); - return apiKey; - }), - Effect.provide(ApiKeyService.DefaultWithoutDependencies), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('apiSecretKey'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + const apiKey = yield* apiKeyService.getApiKeyById(nonExistentId); + return apiKey; + }), + Effect.provide(ApiKeyService.DefaultWithoutDependencies), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(ApiKeyNotFoundError); - }); + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(ApiKeyNotFoundError); + }); - test("should fail to delete non-existent secret key", async (t) => { - const h = await IntegrationHarness.init(t); + test('should fail to delete non-existent secret key', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("apiSecretKey"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - yield* apiKeyService.deleteSecretKey({ - secretKeyId: nonExistentId, - }); - return "deleted"; - }), - Effect.provide(ApiKeyService.DefaultWithoutDependencies), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('apiSecretKey'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + yield* apiKeyService.deleteSecretKey({ + secretKeyId: nonExistentId + }); + return 'deleted'; + }), + Effect.provide(ApiKeyService.DefaultWithoutDependencies), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(ApiKeyNotFoundError); - }); + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(ApiKeyNotFoundError); + }); - test("should fail to rotate non-existent secret key", async (t) => { - const h = await IntegrationHarness.init(t); + test('should fail to rotate non-existent secret key', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("apiSecretKey"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - const rotatedKey = yield* apiKeyService.rotateSecretKey({ - secretKeyId: nonExistentId, - }); - return rotatedKey; - }), - Effect.provide(ApiKeyService.DefaultWithoutDependencies), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('apiSecretKey'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + const rotatedKey = yield* apiKeyService.rotateSecretKey({ + secretKeyId: nonExistentId + }); + return rotatedKey; + }), + Effect.provide(ApiKeyService.DefaultWithoutDependencies), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(ApiKeyNotFoundError); - }); + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(ApiKeyNotFoundError); + }); }); diff --git a/apps/web/lib/services/tests/api-key.service.happy.integration.test.ts b/apps/web/lib/services/tests/api-key.service.happy.integration.test.ts index df509e570..708000fe6 100644 --- a/apps/web/lib/services/tests/api-key.service.happy.integration.test.ts +++ b/apps/web/lib/services/tests/api-key.service.happy.integration.test.ts @@ -1,263 +1,263 @@ -import { describe, expect, test } from "vitest"; -import { Effect, Exit, pipe } from "effect"; -import { AuthSession } from "../auth.service"; -import { createMockEnvironment } from "../../testing/__mocks__/environment.mock"; -import { Environment } from "../environment.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/constants"; -import { ApiKeyService } from "../api-key.service"; -import { IntegrationHarness } from "../../testing/integration-harness"; -import { createIntegrationTestRunner } from "../../effect/runtimes/integration-test"; -import { ApiKeyRepository } from "@/lib/repositories/api-key.repository"; -import { generateId } from "@/lib/id/generate"; -import { hashKey } from "@/lib/core/api-keys/effect/utils"; -import { apiKeys, eq } from "@voidhash/db"; +import { apiKeys, eq } from '@voidhash/db'; +import { Environment as EnvironmentEnum } from '@voidhash/lib/constants'; +import { Effect, Exit, pipe } from 'effect'; +import { describe, expect, test } from 'vitest'; +import { hashKey } from '@/lib/core/api-keys/effect/utils'; +import { generateId } from '@/lib/id/generate'; +import { ApiKeyRepository } from '@/lib/repositories/api-key.repository'; +import { createIntegrationTestRunner } from '../../effect/runtimes/integration-test'; +import { createMockEnvironment } from '../../testing/__mocks__/environment.mock'; +import { IntegrationHarness } from '../../testing/integration-harness'; +import { ApiKeyService } from '../api-key.service'; +import { AuthSession } from '../auth.service'; +import { Environment } from '../environment.service'; -describe.sequential("ApiKeyService happy path", () => { - test("should create a secret key successfully", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('ApiKeyService happy path', () => { + test('should create a secret key successfully', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const input = { - projectId: h.resources.project.id, - name: "Test API Key", - }; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - const secretKey = yield* apiKeyService.createSecretKey(input); - return secretKey; - }), - Effect.provide(ApiKeyService.DefaultWithoutDependencies), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const input = { + projectId: h.resources.project.id, + name: 'Test API Key' + }; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + const secretKey = yield* apiKeyService.createSecretKey(input); + return secretKey; + }), + Effect.provide(ApiKeyService.DefaultWithoutDependencies), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - expect(value).toMatchObject({ - projectId: h.resources.project.id, - name: "Test API Key", - }); - expect(value.rawKey).not.toBe(value.key); - expect(value.end).toBe(value.rawKey.slice(-4)); + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + expect(value).toMatchObject({ + projectId: h.resources.project.id, + name: 'Test API Key' + }); + expect(value.rawKey).not.toBe(value.key); + expect(value.end).toBe(value.rawKey.slice(-4)); - t.onTestFinished(async () => { - if (value?.id) { - await h.db.primary.delete(apiKeys).where(eq(apiKeys.id, value.id)); - } - }); - }); + t.onTestFinished(async () => { + if (value?.id) { + await h.db.primary.delete(apiKeys).where(eq(apiKeys.id, value.id)); + } + }); + }); - test("should get API keys for a project", async (t) => { - const h = await IntegrationHarness.init(t); + test('should get API keys for a project', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - const apiKeyRepository = yield* ApiKeyRepository; - // Test Api Key - const unhashedTestKey = "test-secret-key"; - const hashedTestKey = yield* hashKey(unhashedTestKey); - yield* apiKeyRepository.createApiKey({ - id: generateId("test"), - name: "Test Secret Key", - key: hashedTestKey, - createdAt: new Date(), - updatedAt: new Date(), - prefix: "test_", - end: "1234", - isPublic: false, - environment: EnvironmentEnum.Testing, - projectId: h.resources.project.id, - }); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + const apiKeyRepository = yield* ApiKeyRepository; + // Test Api Key + const unhashedTestKey = 'test-secret-key'; + const hashedTestKey = yield* hashKey(unhashedTestKey); + yield* apiKeyRepository.createApiKey({ + id: generateId('test'), + name: 'Test Secret Key', + key: hashedTestKey, + createdAt: new Date(), + updatedAt: new Date(), + prefix: 'test_', + end: '1234', + isPublic: false, + environment: EnvironmentEnum.Testing, + projectId: h.resources.project.id + }); - // Api key, different project - const unhashedDifferentProjectKey = "test-secret-key-2"; - const hashedDifferentProjectKey = yield* hashKey( - unhashedDifferentProjectKey, - ); - yield* apiKeyRepository.createApiKey({ - id: generateId("test"), - name: "Test Secret Key 2", - key: hashedDifferentProjectKey, - createdAt: new Date(), - updatedAt: new Date(), - prefix: "test_", - end: "1234", - isPublic: false, - environment: EnvironmentEnum.Production, - projectId: generateId("test"), - }); + // Api key, different project + const unhashedDifferentProjectKey = 'test-secret-key-2'; + const hashedDifferentProjectKey = yield* hashKey( + unhashedDifferentProjectKey + ); + yield* apiKeyRepository.createApiKey({ + id: generateId('test'), + name: 'Test Secret Key 2', + key: hashedDifferentProjectKey, + createdAt: new Date(), + updatedAt: new Date(), + prefix: 'test_', + end: '1234', + isPublic: false, + environment: EnvironmentEnum.Production, + projectId: generateId('test') + }); - const apiKeys = yield* apiKeyService.getApiKeys( - h.resources.project.id, - ); + const apiKeys = yield* apiKeyService.getApiKeys( + h.resources.project.id + ); - return apiKeys; - }), - Effect.provide(ApiKeyService.DefaultWithoutDependencies), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + return apiKeys; + }), + Effect.provide(ApiKeyService.DefaultWithoutDependencies), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); - // Should return the existing secret key from the harness (both secret and publishable) - expect(value).toHaveLength(2); - const secretKey = value.find((key) => key.isPublic === false); - const publishableKey = value.find((key) => key.isPublic === true); - expect(secretKey).toMatchObject({ - projectId: h.resources.project.id, - name: "Test Secret Key", - environment: EnvironmentEnum.Production, - }); - expect(publishableKey).toMatchObject({ - projectId: h.resources.project.id, - name: "Test Publishable Key", - environment: EnvironmentEnum.Production, - }); - }); + // Should return the existing secret key from the harness (both secret and publishable) + expect(value).toHaveLength(2); + const secretKey = value.find((key) => key.isPublic === false); + const publishableKey = value.find((key) => key.isPublic === true); + expect(secretKey).toMatchObject({ + projectId: h.resources.project.id, + name: 'Test Secret Key', + environment: EnvironmentEnum.Production + }); + expect(publishableKey).toMatchObject({ + projectId: h.resources.project.id, + name: 'Test Publishable Key', + environment: EnvironmentEnum.Production + }); + }); - test("should get API key by ID", async (t) => { - const h = await IntegrationHarness.init(t); + test('should get API key by ID', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - const apiKey = yield* apiKeyService.getApiKeyById( - h.resources.secretKey.id, - ); - return apiKey; - }), - Effect.provide(ApiKeyService.DefaultWithoutDependencies), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + const apiKey = yield* apiKeyService.getApiKeyById( + h.resources.secretKey.id + ); + return apiKey; + }), + Effect.provide(ApiKeyService.DefaultWithoutDependencies), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); - expect(value).toMatchObject({ - id: h.resources.secretKey.id, - projectId: h.resources.project.id, - name: "Test Secret Key", - environment: EnvironmentEnum.Production, - }); - }); + expect(value).toMatchObject({ + id: h.resources.secretKey.id, + projectId: h.resources.project.id, + name: 'Test Secret Key', + environment: EnvironmentEnum.Production + }); + }); - test("should delete a secret key successfully", async (t) => { - const h = await IntegrationHarness.init(t); + test('should delete a secret key successfully', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - yield* apiKeyService.deleteSecretKey({ - secretKeyId: h.resources.secretKey.id, - }); - return "deleted"; - }), - Effect.provide(ApiKeyService.DefaultWithoutDependencies), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + yield* apiKeyService.deleteSecretKey({ + secretKeyId: h.resources.secretKey.id + }); + return 'deleted'; + }), + Effect.provide(ApiKeyService.DefaultWithoutDependencies), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); - expect(value).toBe("deleted"); - }); + expect(value).toBe('deleted'); + }); - test("should rotate a secret key successfully", async (t) => { - const h = await IntegrationHarness.init(t); + test('should rotate a secret key successfully', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const apiKeyService = yield* ApiKeyService; - const rotatedKey = yield* apiKeyService.rotateSecretKey({ - secretKeyId: h.resources.secretKey.id, - }); - return rotatedKey; - }), - Effect.provide(ApiKeyService.DefaultWithoutDependencies), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const apiKeyService = yield* ApiKeyService; + const rotatedKey = yield* apiKeyService.rotateSecretKey({ + secretKeyId: h.resources.secretKey.id + }); + return rotatedKey; + }), + Effect.provide(ApiKeyService.DefaultWithoutDependencies), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); - expect(value).toMatchObject({ - id: h.resources.secretKey.id, - projectId: h.resources.project.id, - name: "Test Secret Key", - environment: EnvironmentEnum.Production, - }); - expect(value.rawKey).not.toBe(h.resources.secretKey.unhashedKey); - expect(value.end).toBe(value.rawKey.slice(-4)); - }); + expect(value).toMatchObject({ + id: h.resources.secretKey.id, + projectId: h.resources.project.id, + name: 'Test Secret Key', + environment: EnvironmentEnum.Production + }); + expect(value.rawKey).not.toBe(h.resources.secretKey.unhashedKey); + expect(value.end).toBe(value.rawKey.slice(-4)); + }); }); diff --git a/apps/web/lib/services/tests/customer.service.error.integration.test.ts b/apps/web/lib/services/tests/customer.service.error.integration.test.ts index cc1686dfb..b73e39755 100644 --- a/apps/web/lib/services/tests/customer.service.error.integration.test.ts +++ b/apps/web/lib/services/tests/customer.service.error.integration.test.ts @@ -1,209 +1,209 @@ -import { describe, expect, test } from "vitest"; -import { Cause, Effect, Exit, pipe } from "effect"; -import { AuthSession } from "../auth.service"; -import { createMockEnvironment } from "../../testing/__mocks__/environment.mock"; -import { Environment } from "../environment.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/constants"; +import { CustomerOrigin, customers, eq } from '@voidhash/db'; +import { Environment as EnvironmentEnum } from '@voidhash/lib/constants'; +import { Cause, Effect, Exit, pipe } from 'effect'; +import { describe, expect, test } from 'vitest'; +import { ANONYMOUS_USER_ID_PREFIX } from '@/lib/core/sdk/constants'; +import { generateId } from '@/lib/id/generate'; +import { createIntegrationTestRunner } from '../../effect/runtimes/integration-test'; +import { createMockEnvironment } from '../../testing/__mocks__/environment.mock'; +import { IntegrationHarness } from '../../testing/integration-harness'; +import { AuthSession } from '../auth.service'; import { - CustomerNotFoundError, - CustomerService, - InvalidAnonymousIdError, -} from "../customer.service"; -import { IntegrationHarness } from "../../testing/integration-harness"; -import { createIntegrationTestRunner } from "../../effect/runtimes/integration-test"; -import { generateId } from "@/lib/id/generate"; -import { CustomerOrigin, customers, eq } from "@voidhash/db"; - -describe.sequential("CustomerService error path", () => { - test("should fail to create an anonymous customer with invalid app user ID", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const input = { - projectId: h.resources.project.id, - appUserId: `test-anonymous-user-id`, - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Production, - }; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const customer = - yield* customerService.createAnonymousCustomer(input); - return customer; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(InvalidAnonymousIdError); - - t.onTestFinished(async () => { - await h.db.primary - .delete(customers) - .where(eq(customers.appUserId, input.appUserId)); - }); - }); - - test("should fail to get customer by non-existent ID", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("customer"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const customer = - yield* customerService.getCustomerById(nonExistentId); - return customer; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(CustomerNotFoundError); - }); - - test("should fail to get customer by non-existent app user ID", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentAppUserId = "non-existent-app-user-id"; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const customer = - yield* customerService.getCustomerByAppUserId( - nonExistentAppUserId, - ); - return customer; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(CustomerNotFoundError); - }); - - test("should fail to get customer unlocked perks for non-existent customer", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("customer"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const perks = - yield* customerService.getCustomersUnlockedPerks(nonExistentId); - return perks; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(CustomerNotFoundError); - }); - - test("should fail to get customer purchases for non-existent customer", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("customer"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const purchases = - yield* customerService.getCustomerPurchases(nonExistentId); - return purchases; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(CustomerNotFoundError); - }); - - // test("should fail to merge customers with invalid anonymous ID", async (t) => { - // const h = await IntegrationHarness.init(t); - - // const integrationTestRunner = createIntegrationTestRunner("hono"); - // const result = await integrationTestRunner( - // Effect.gen(function* () { - // return yield* pipe( - // Effect.gen(function* () { - // const customerService = yield* CustomerService; - // const customer = yield* customerService.mergeCustomers( - // generateId("test"), - // generateId("test"), - // ); - // return customer; - // }), - // ); - // }), - // ); - - // expect(Exit.isFailure(result)).toBe(true); - // const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - // expect(error).toBeInstanceOf(InvalidAnonymousIdError); - // }); + CustomerNotFoundError, + CustomerService, + InvalidAnonymousIdError +} from '../customer.service'; +import { Environment } from '../environment.service'; + +describe.sequential('CustomerService error path', () => { + test('should fail to create an anonymous customer with invalid app user ID', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const input = { + projectId: h.resources.project.id, + appUserId: `${ANONYMOUS_USER_ID_PREFIX}test-anonymous-user-id`, + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Production + }; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const customer = yield* customerService.createCustomer(input); + return customer; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(InvalidAnonymousIdError); + + t.onTestFinished(async () => { + await h.db.primary + .delete(customers) + .where(eq(customers.appUserId, input.appUserId)); + }); + }); + + test('should fail to get customer by non-existent ID', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('customer'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const customer = + yield* customerService.getCustomerById(nonExistentId); + return customer; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(CustomerNotFoundError); + }); + + test('should fail to get customer by non-existent app user ID', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentAppUserId = 'non-existent-app-user-id'; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const customer = + yield* customerService.getCustomerByAppUserId( + nonExistentAppUserId + ); + return customer; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(CustomerNotFoundError); + }); + + test('should fail to get customer unlocked perks for non-existent customer', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('customer'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const perks = + yield* customerService.getCustomersUnlockedPerks(nonExistentId); + return perks; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(CustomerNotFoundError); + }); + + test('should fail to get customer purchases for non-existent customer', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('customer'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const purchases = + yield* customerService.getCustomerPurchases(nonExistentId); + return purchases; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(CustomerNotFoundError); + }); + + // test("should fail to merge customers with invalid anonymous ID", async (t) => { + // const h = await IntegrationHarness.init(t); + + // const integrationTestRunner = createIntegrationTestRunner("hono"); + // const result = await integrationTestRunner( + // Effect.gen(function* () { + // return yield* pipe( + // Effect.gen(function* () { + // const customerService = yield* CustomerService; + // const customer = yield* customerService.mergeCustomers( + // generateId("test"), + // generateId("test"), + // ); + // return customer; + // }), + // ); + // }), + // ); + + // expect(Exit.isFailure(result)).toBe(true); + // const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + // expect(error).toBeInstanceOf(InvalidAnonymousIdError); + // }); }); diff --git a/apps/web/lib/services/tests/customer.service.happy.integration.test.ts b/apps/web/lib/services/tests/customer.service.happy.integration.test.ts index 78ae8c250..3a215dcfa 100644 --- a/apps/web/lib/services/tests/customer.service.happy.integration.test.ts +++ b/apps/web/lib/services/tests/customer.service.happy.integration.test.ts @@ -1,482 +1,482 @@ -import { describe, expect, test } from "vitest"; -import { Effect, Exit, pipe } from "effect"; -import { AuthSession } from "../auth.service"; -import { createMockEnvironment } from "../../testing/__mocks__/environment.mock"; -import { Environment } from "../environment.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/constants"; -import { CustomerService } from "../customer.service"; -import { IntegrationHarness } from "../../testing/integration-harness"; -import { createIntegrationTestRunner } from "../../effect/runtimes/integration-test"; -import { CustomerRepository } from "@/lib/repositories/customer.repository"; -import { generateId } from "@/lib/id/generate"; -import { customers, eq, CustomerOrigin, CustomerType, or } from "@voidhash/db"; -import { ANONYMOUS_USER_ID_PREFIX } from "@/lib/core/sdk/constants"; - -describe.sequential("CustomerService happy path", () => { - test("should create a customer successfully", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const input = { - projectId: h.resources.project.id, - appUserId: "test-app-user-id", - name: "Test Customer", - email: "test@example.com", - origin: CustomerOrigin.Dashboard, - }; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const customer = yield* customerService.createCustomer(input); - return customer; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - expect(value).toMatchObject({ - projectId: h.resources.project.id, - appUserId: "test-app-user-id", - name: "Test Customer", - email: "test@example.com", - type: CustomerType.Identified, - origin: CustomerOrigin.Dashboard, - }); - - t.onTestFinished(async () => { - if (value?.id) { - await h.db.primary.delete(customers).where(eq(customers.id, value.id)); - } - }); - }); - - test("should create an anonymous customer successfully", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const input = { - projectId: h.resources.project.id, - appUserId: `${ANONYMOUS_USER_ID_PREFIX}test-anonymous-user-id`, - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Production, - }; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const customer = - yield* customerService.createAnonymousCustomer(input); - return customer; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - expect(value).toMatchObject({ - projectId: h.resources.project.id, - appUserId: `${ANONYMOUS_USER_ID_PREFIX}test-anonymous-user-id`, - origin: CustomerOrigin.Dashboard, - type: CustomerType.Anonymous, - environment: EnvironmentEnum.Production, - }); - - t.onTestFinished(async () => { - if (value?.id) { - await h.db.primary.delete(customers).where(eq(customers.id, value.id)); - } - }); - }); - - test("should get customers for a project", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const customerRepository = yield* CustomerRepository; - - // Create a test customer - const testCustomer = { - id: generateId("test"), - projectId: h.resources.project.id, - appUserId: "test-customer-user-id", - name: "Test Customer", - email: "test@example.com", - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Production, - type: 1, // Identified - parentCustomerId: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - // Create a test customer for different project - const testCustomerDifferentProject = { - id: generateId("test"), - projectId: generateId("test"), - appUserId: "test-customer-user-id", - name: "Test Customer", - email: "test@example.com", - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Production, - type: 1, // Identified - parentCustomerId: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - // Create a test customer different environment - const testCustomerDifferentEnvironment = { - id: generateId("test"), - projectId: h.resources.project.id, - appUserId: "test-customer-user-id", - name: "Test Customer", - email: "test@example.com", - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Testing, - type: 1, // Identified - parentCustomerId: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - yield* customerRepository.createCustomer(testCustomer); - yield* customerRepository.createCustomer( - testCustomerDifferentProject, - ); - yield* customerRepository.createCustomer( - testCustomerDifferentEnvironment, - ); - - const customers = yield* customerService.getCustomers({ - projectId: h.resources.project.id, - }); - - return customers; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(Exit.isSuccess(result)).toBe(true); - - expect(value.length).toBeGreaterThan(0); - const testCustomer = value.find( - (c) => c.appUserId === "test-customer-user-id", - ); - expect(testCustomer).toMatchObject({ - projectId: h.resources.project.id, - appUserId: "test-customer-user-id", - name: "Test Customer", - email: "test@example.com", - }); - - t.onTestFinished(async () => { - await h.db.primary - .delete(customers) - .where(eq(customers.appUserId, "test-customer-user-id")); - }); - }); - - test("should get customer by ID", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const customerRepository = yield* CustomerRepository; - - // Create a test customer - const testCustomer = { - id: generateId("test"), - projectId: h.resources.project.id, - appUserId: "test-customer-by-id", - name: "Test Customer By ID", - email: "test-by-id@example.com", - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Production, - type: 1, // Identified - parentCustomerId: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - yield* customerRepository.createCustomer(testCustomer); - - const customer = yield* customerService.getCustomerById( - testCustomer.id, - ); - return customer; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(value).toMatchObject({ - appUserId: "test-customer-by-id", - name: "Test Customer By ID", - email: "test-by-id@example.com", - projectId: h.resources.project.id, - }); - - t.onTestFinished(async () => { - await h.db.primary - .delete(customers) - .where(eq(customers.appUserId, "test-customer-by-id")); - }); - }); - - test("should get customer by app user ID", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const testCustomerId = generateId("test"); - const testCustomerDifferentProjectId = generateId("test"); - const testCustomerDifferentEnvironmentId = generateId("test"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const customerRepository = yield* CustomerRepository; - - // Create a test customer - const testCustomer = { - id: testCustomerId, - projectId: h.resources.project.id, - appUserId: "test-customer-by-app-user-id", - name: "Test Customer By App User ID", - email: "test-by-app-user-id@example.com", - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Production, - type: 1, // Identified - parentCustomerId: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - // Create a test customer for different project - const testCustomerDifferentProject = { - id: testCustomerDifferentProjectId, - projectId: generateId("test"), - appUserId: "test-customer-by-app-user-id", - name: "Test Customer", - email: "test@example.com", - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Production, - type: 1, // Identified - parentCustomerId: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - // Create a test customer different environment - const testCustomerDifferentEnvironment = { - id: testCustomerDifferentEnvironmentId, - projectId: h.resources.project.id, - appUserId: "test-customer-by-app-user-id", - name: "Test Customer", - email: "test@example.com", - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Testing, - type: 1, // Identified - parentCustomerId: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - yield* customerRepository.createCustomer( - testCustomerDifferentProject, - ); - yield* customerRepository.createCustomer( - testCustomerDifferentEnvironment, - ); - yield* customerRepository.createCustomer(testCustomer); - - const customer = yield* customerService.getCustomerByAppUserId( - "test-customer-by-app-user-id", - ); - return customer; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(value).toMatchObject({ - id: testCustomerId, - appUserId: "test-customer-by-app-user-id", - name: "Test Customer By App User ID", - email: "test-by-app-user-id@example.com", - projectId: h.resources.project.id, - }); - - t.onTestFinished(async () => { - await h.db.primary - .delete(customers) - .where( - or( - eq(customers.id, testCustomerId), - eq(customers.id, testCustomerDifferentProjectId), - eq(customers.id, testCustomerDifferentEnvironmentId), - ), - ); - }); - }); - - test("should merge customers successfully", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const customerService = yield* CustomerService; - const customerRepository = yield* CustomerRepository; - - // Create two test customers - const fromCustomer = { - id: generateId("customer"), - projectId: h.resources.project.id, - appUserId: `${ANONYMOUS_USER_ID_PREFIX}from-customer`, - name: "From Customer", - email: "from@example.com", - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Production, - type: 1, // Identified - parentCustomerId: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - const toCustomer = { - id: generateId("customer"), - projectId: h.resources.project.id, - appUserId: "to-customer", - name: "To Customer", - email: "to@example.com", - origin: CustomerOrigin.Dashboard, - environment: EnvironmentEnum.Production, - type: 1, // Identified - parentCustomerId: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - yield* customerRepository.createCustomer(fromCustomer); - yield* customerRepository.createCustomer(toCustomer); - - const result = yield* customerService.mergeCustomers( - fromCustomer.id, - toCustomer.id, - ); - return result; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(value).toMatchObject({ - id: expect.any(String), - }); - - t.onTestFinished(async () => { - await h.db.primary - .delete(customers) - .where(eq(customers.appUserId, "from-customer")); - await h.db.primary - .delete(customers) - .where(eq(customers.appUserId, "to-customer")); - }); - }); +import { CustomerOrigin, CustomerType, customers, eq, or } from '@voidhash/db'; +import { Environment as EnvironmentEnum } from '@voidhash/lib/constants'; +import { Effect, Exit, pipe } from 'effect'; +import { describe, expect, test } from 'vitest'; +import { ANONYMOUS_USER_ID_PREFIX } from '@/lib/core/sdk/constants'; +import { generateId } from '@/lib/id/generate'; +import { CustomerRepository } from '@/lib/repositories/customer.repository'; +import { createIntegrationTestRunner } from '../../effect/runtimes/integration-test'; +import { createMockEnvironment } from '../../testing/__mocks__/environment.mock'; +import { IntegrationHarness } from '../../testing/integration-harness'; +import { AuthSession } from '../auth.service'; +import { CustomerService } from '../customer.service'; +import { Environment } from '../environment.service'; + +describe.sequential('CustomerService happy path', () => { + test('should create a customer successfully', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const input = { + projectId: h.resources.project.id, + appUserId: 'test-app-user-id', + name: 'Test Customer', + email: 'test@example.com', + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Production + }; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const customer = yield* customerService.createCustomer(input); + return customer; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + expect(value).toMatchObject({ + projectId: h.resources.project.id, + appUserId: 'test-app-user-id', + name: 'Test Customer', + email: 'test@example.com', + type: CustomerType.Identified, + origin: CustomerOrigin.Dashboard + }); + + t.onTestFinished(async () => { + if (value?.id) { + await h.db.primary.delete(customers).where(eq(customers.id, value.id)); + } + }); + }); + + test('should create an anonymous customer successfully', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const input = { + projectId: h.resources.project.id, + appUserId: `${ANONYMOUS_USER_ID_PREFIX}test-anonymous-user-id`, + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Production + }; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const customer = yield* customerService.createCustomer(input); + return customer; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + expect(value).toMatchObject({ + projectId: h.resources.project.id, + appUserId: `${ANONYMOUS_USER_ID_PREFIX}test-anonymous-user-id`, + origin: CustomerOrigin.Dashboard, + type: CustomerType.Anonymous, + environment: EnvironmentEnum.Production + }); + + t.onTestFinished(async () => { + if (value?.id) { + await h.db.primary.delete(customers).where(eq(customers.id, value.id)); + } + }); + }); + + test('should get customers for a project', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const customerRepository = yield* CustomerRepository; + + // Create a test customer + const testCustomer = { + id: generateId('test'), + projectId: h.resources.project.id, + appUserId: 'test-customer-user-id', + name: 'Test Customer', + email: 'test@example.com', + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Production, + type: 1, // Identified + parentCustomerId: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + // Create a test customer for different project + const testCustomerDifferentProject = { + id: generateId('test'), + projectId: generateId('test'), + appUserId: 'test-customer-user-id', + name: 'Test Customer', + email: 'test@example.com', + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Production, + type: 1, // Identified + parentCustomerId: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + // Create a test customer different environment + const testCustomerDifferentEnvironment = { + id: generateId('test'), + projectId: h.resources.project.id, + appUserId: 'test-customer-user-id', + name: 'Test Customer', + email: 'test@example.com', + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Testing, + type: 1, // Identified + parentCustomerId: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + yield* customerRepository.createCustomer(testCustomer); + yield* customerRepository.createCustomer( + testCustomerDifferentProject + ); + yield* customerRepository.createCustomer( + testCustomerDifferentEnvironment + ); + + const customers = yield* customerService.getCustomers({ + projectId: h.resources.project.id + }); + + return customers; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(Exit.isSuccess(result)).toBe(true); + + expect(value.length).toBeGreaterThan(0); + const testCustomer = value.find( + (c) => c.appUserId === 'test-customer-user-id' + ); + expect(testCustomer).toMatchObject({ + projectId: h.resources.project.id, + appUserId: 'test-customer-user-id', + name: 'Test Customer', + email: 'test@example.com' + }); + + t.onTestFinished(async () => { + await h.db.primary + .delete(customers) + .where(eq(customers.appUserId, 'test-customer-user-id')); + }); + }); + + test('should get customer by ID', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const customerRepository = yield* CustomerRepository; + + // Create a test customer + const testCustomer = { + id: generateId('test'), + projectId: h.resources.project.id, + appUserId: 'test-customer-by-id', + name: 'Test Customer By ID', + email: 'test-by-id@example.com', + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Production, + type: 1, // Identified + parentCustomerId: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + }; + yield* customerRepository.createCustomer(testCustomer); + + const customer = yield* customerService.getCustomerById( + testCustomer.id + ); + return customer; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(value).toMatchObject({ + appUserId: 'test-customer-by-id', + name: 'Test Customer By ID', + email: 'test-by-id@example.com', + projectId: h.resources.project.id + }); + + t.onTestFinished(async () => { + await h.db.primary + .delete(customers) + .where(eq(customers.appUserId, 'test-customer-by-id')); + }); + }); + + test('should get customer by app user ID', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const testCustomerId = generateId('test'); + const testCustomerDifferentProjectId = generateId('test'); + const testCustomerDifferentEnvironmentId = generateId('test'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const customerRepository = yield* CustomerRepository; + + // Create a test customer + const testCustomer = { + id: testCustomerId, + projectId: h.resources.project.id, + appUserId: 'test-customer-by-app-user-id', + name: 'Test Customer By App User ID', + email: 'test-by-app-user-id@example.com', + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Production, + type: 1, // Identified + parentCustomerId: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + // Create a test customer for different project + const testCustomerDifferentProject = { + id: testCustomerDifferentProjectId, + projectId: generateId('test'), + appUserId: 'test-customer-by-app-user-id', + name: 'Test Customer', + email: 'test@example.com', + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Production, + type: 1, // Identified + parentCustomerId: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + // Create a test customer different environment + const testCustomerDifferentEnvironment = { + id: testCustomerDifferentEnvironmentId, + projectId: h.resources.project.id, + appUserId: 'test-customer-by-app-user-id', + name: 'Test Customer', + email: 'test@example.com', + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Testing, + type: 1, // Identified + parentCustomerId: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + yield* customerRepository.createCustomer( + testCustomerDifferentProject + ); + yield* customerRepository.createCustomer( + testCustomerDifferentEnvironment + ); + yield* customerRepository.createCustomer(testCustomer); + + const customer = yield* customerService.getCustomerByAppUserId( + 'test-customer-by-app-user-id' + ); + return customer; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(value).toMatchObject({ + id: testCustomerId, + appUserId: 'test-customer-by-app-user-id', + name: 'Test Customer By App User ID', + email: 'test-by-app-user-id@example.com', + projectId: h.resources.project.id + }); + + t.onTestFinished(async () => { + await h.db.primary + .delete(customers) + .where( + or( + eq(customers.id, testCustomerId), + eq(customers.id, testCustomerDifferentProjectId), + eq(customers.id, testCustomerDifferentEnvironmentId) + ) + ); + }); + }); + + test('should merge customers successfully', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const customerService = yield* CustomerService; + const customerRepository = yield* CustomerRepository; + + // Create two test customers + const fromCustomer = { + id: generateId('customer'), + projectId: h.resources.project.id, + appUserId: `${ANONYMOUS_USER_ID_PREFIX}from-customer`, + name: 'From Customer', + email: 'from@example.com', + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Production, + type: 1, // Identified + parentCustomerId: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + }; + const toCustomer = { + id: generateId('customer'), + projectId: h.resources.project.id, + appUserId: 'to-customer', + name: 'To Customer', + email: 'to@example.com', + origin: CustomerOrigin.Dashboard, + environment: EnvironmentEnum.Production, + type: 1, // Identified + parentCustomerId: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + }; + yield* customerRepository.createCustomer(fromCustomer); + yield* customerRepository.createCustomer(toCustomer); + + const result = yield* customerService.mergeCustomers( + fromCustomer.id, + toCustomer.id + ); + return result; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(value).toMatchObject({ + id: expect.any(String) + }); + + t.onTestFinished(async () => { + await h.db.primary + .delete(customers) + .where(eq(customers.appUserId, 'from-customer')); + await h.db.primary + .delete(customers) + .where(eq(customers.appUserId, 'to-customer')); + }); + }); }); diff --git a/apps/web/lib/services/tests/organization.service.error.integration.test.ts b/apps/web/lib/services/tests/organization.service.error.integration.test.ts index 95f69fc1a..77b301198 100644 --- a/apps/web/lib/services/tests/organization.service.error.integration.test.ts +++ b/apps/web/lib/services/tests/organization.service.error.integration.test.ts @@ -1,125 +1,125 @@ -import { describe, expect, test } from "vitest"; -import { Cause, Effect, Exit, pipe } from "effect"; -import { AuthSession } from "../auth.service"; +import { Cause, Effect, Exit, pipe } from 'effect'; +import { describe, expect, test } from 'vitest'; +import { generateId } from '@/lib/id/generate'; +import { createIntegrationTestRunner } from '../../effect/runtimes/integration-test'; +import { IntegrationHarness } from '../../testing/integration-harness'; +import { AuthSession } from '../auth.service'; import { - OrganizationService, - OrganizationNotFound, -} from "../organization.service"; -import { IntegrationHarness } from "../../testing/integration-harness"; -import { createIntegrationTestRunner } from "../../effect/runtimes/integration-test"; -import { generateId } from "@/lib/id/generate"; + OrganizationNotFound, + OrganizationService +} from '../organization.service'; -describe.sequential("OrganizationService error path", () => { - test("should fail to get organization by non-existent slug", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('OrganizationService error path', () => { + test('should fail to get organization by non-existent slug', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentSlug = "non-existent-organization"; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const organization = - yield* organizationService.getOrganizationBySlug(nonExistentSlug); - return organization; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentSlug = 'non-existent-organization'; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const organization = + yield* organizationService.getOrganizationBySlug(nonExistentSlug); + return organization; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ) + ); + }) + ); - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(OrganizationNotFound); - }); + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(OrganizationNotFound); + }); - test("should fail to get organization by non-existent ID", async (t) => { - const h = await IntegrationHarness.init(t); + test('should fail to get organization by non-existent ID', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("test"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const organization = - yield* organizationService.getOrganizationById(nonExistentId); - return organization; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('test'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const organization = + yield* organizationService.getOrganizationById(nonExistentId); + return organization; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ) + ); + }) + ); - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(OrganizationNotFound); - }); + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(OrganizationNotFound); + }); - test("should fail to update non-existent organization", async (t) => { - const h = await IntegrationHarness.init(t); + test('should fail to update non-existent organization', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("test"); - const input = { - organizationId: nonExistentId, - name: "Updated Organization Name", - }; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - yield* organizationService.updateOrganization(input); - return "updated"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('test'); + const input = { + organizationId: nonExistentId, + name: 'Updated Organization Name' + }; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + yield* organizationService.updateOrganization(input); + return 'updated'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ) + ); + }) + ); - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(OrganizationNotFound); - }); + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(OrganizationNotFound); + }); - test("should fail to delete non-existent organization", async (t) => { - const h = await IntegrationHarness.init(t); + test('should fail to delete non-existent organization', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("test"); - const input = { - organizationId: nonExistentId, - }; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - yield* organizationService.deleteOrganization(input); - return "deleted"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('test'); + const input = { + organizationId: nonExistentId + }; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + yield* organizationService.deleteOrganization(input); + return 'deleted'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ) + ); + }) + ); - // This might succeed or fail depending on the implementation - // For now, we'll just check that it doesn't throw an unexpected error - expect(Exit.isSuccess(result) || Exit.isFailure(result)).toBe(true); - }); + // This might succeed or fail depending on the implementation + // For now, we'll just check that it doesn't throw an unexpected error + expect(Exit.isSuccess(result) || Exit.isFailure(result)).toBe(true); + }); }); diff --git a/apps/web/lib/services/tests/organization.service.happy.integration.test.ts b/apps/web/lib/services/tests/organization.service.happy.integration.test.ts index 6c7beae90..4dedb19ae 100644 --- a/apps/web/lib/services/tests/organization.service.happy.integration.test.ts +++ b/apps/web/lib/services/tests/organization.service.happy.integration.test.ts @@ -1,181 +1,184 @@ -import { describe, expect, it, test } from "vitest"; -import { Effect, Exit, pipe } from "effect"; -import { AuthSession } from "../auth.service"; -import { OrganizationService } from "../organization.service"; -import { IntegrationHarness } from "../../testing/integration-harness"; -import { createIntegrationTestRunner } from "../../effect/runtimes/integration-test"; - -describe.sequential("OrganizationService happy path", () => { - // TODO: Fix this test, it's failing because the better-auth cookie is not set - it.skip("should create an organization successfully", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const input = { - name: "Test Organization", - }; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const organization = - yield* organizationService.createOrganization(input); - return organization; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - ); - }), - ); - - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - console.log(value); - expect(Exit.isSuccess(result)).toBe(true); - expect(value).toMatchObject({ - name: "Test Organization", - }); - expect(value.id).toBeDefined(); - expect(value.slug).toBeDefined(); - - // Note: Organization cleanup is handled by the auth system, not directly in tests - }); - - test("should get organization by slug", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const organization = - yield* organizationService.getOrganizationBySlug( - h.resources.organization.slug!, - ); - return organization; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(value).toMatchObject({ - id: h.resources.organization.id, - name: h.resources.organization.name, - slug: h.resources.organization.slug, - }); - }); - - test("should get organization by ID", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - const organization = yield* organizationService.getOrganizationById( - h.resources.organization.id, - ); - return organization; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(value).toMatchObject({ - id: h.resources.organization.id, - name: h.resources.organization.name, - slug: h.resources.organization.slug, - }); - }); - - // TODO: Fix this test, it's failing because the better-auth cookie is not set - it.skip("should update organization successfully", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const input = { - organizationId: h.resources.organization.id, - name: "Updated Organization Name", - }; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - yield* organizationService.updateOrganization(input); - return "updated"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(value).toBe("updated"); - }); - - // TODO: Fix this test, it's failing because the better-auth cookie is not set - it.skip("should delete organization successfully", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const input = { - organizationId: h.resources.organization.id, - }; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const organizationService = yield* OrganizationService; - yield* organizationService.deleteOrganization(input); - return "deleted"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(value).toBe("deleted"); - }); +import { Effect, Exit, pipe } from 'effect'; +import { describe, expect, it, test } from 'vitest'; +import { createIntegrationTestRunner } from '../../effect/runtimes/integration-test'; +import { IntegrationHarness } from '../../testing/integration-harness'; +import { AuthSession } from '../auth.service'; +import { OrganizationService } from '../organization.service'; + +describe.sequential('OrganizationService happy path', () => { + // TODO: Fix this test, it's failing because the better-auth cookie is not set + // biome-ignore lint/suspicious/noSkippedTests: TODO + it.skip('should create an organization successfully', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const input = { + name: 'Test Organization' + }; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const organization = + yield* organizationService.createOrganization(input); + return organization; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ) + ); + }) + ); + + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + expect(Exit.isSuccess(result)).toBe(true); + expect(value).toMatchObject({ + name: 'Test Organization' + }); + expect(value.id).toBeDefined(); + expect(value.slug).toBeDefined(); + + // Note: Organization cleanup is handled by the auth system, not directly in tests + }); + + test('should get organization by slug', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const organization = + yield* organizationService.getOrganizationBySlug( + // biome-ignore lint/style/noNonNullAssertion: allways true, ok in test + h.resources.organization.slug! + ); + return organization; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(value).toMatchObject({ + id: h.resources.organization.id, + name: h.resources.organization.name, + slug: h.resources.organization.slug + }); + }); + + test('should get organization by ID', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + const organization = yield* organizationService.getOrganizationById( + h.resources.organization.id + ); + return organization; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(value).toMatchObject({ + id: h.resources.organization.id, + name: h.resources.organization.name, + slug: h.resources.organization.slug + }); + }); + + // TODO: Fix this test, it's failing because the better-auth cookie is not set + // biome-ignore lint/suspicious/noSkippedTests: TODO + it.skip('should update organization successfully', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const input = { + organizationId: h.resources.organization.id, + name: 'Updated Organization Name' + }; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + yield* organizationService.updateOrganization(input); + return 'updated'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(value).toBe('updated'); + }); + + // TODO: Fix this test, it's failing because the better-auth cookie is not set + // biome-ignore lint/suspicious/noSkippedTests: TODO + it.skip('should delete organization successfully', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const input = { + organizationId: h.resources.organization.id + }; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const organizationService = yield* OrganizationService; + yield* organizationService.deleteOrganization(input); + return 'deleted'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(value).toBe('deleted'); + }); }); diff --git a/apps/web/lib/services/tests/paywall-location.service.error.integration.test.ts b/apps/web/lib/services/tests/paywall-location.service.error.integration.test.ts index 564f94433..3e6ddaedb 100644 --- a/apps/web/lib/services/tests/paywall-location.service.error.integration.test.ts +++ b/apps/web/lib/services/tests/paywall-location.service.error.integration.test.ts @@ -1,179 +1,179 @@ -import { describe, expect, test } from "vitest"; -import { Cause, Effect, Exit, pipe } from "effect"; -import { AuthSession } from "../auth.service"; -import { createMockEnvironment } from "../../testing/__mocks__/environment.mock"; -import { Environment } from "../environment.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/constants"; +import { Environment as EnvironmentEnum } from '@voidhash/lib/constants'; +import { Cause, Effect, Exit, pipe } from 'effect'; +import { describe, expect, test } from 'vitest'; +import { generateId } from '@/lib/id/generate'; +import { PaywallRepository } from '@/lib/repositories/paywall.repository'; +import { createIntegrationTestRunner } from '../../effect/runtimes/integration-test'; +import { createMockEnvironment } from '../../testing/__mocks__/environment.mock'; +import { IntegrationHarness } from '../../testing/integration-harness'; +import { AuthSession } from '../auth.service'; +import { Environment } from '../environment.service'; import { - PaywallLocationService, - SlugAlreadyExistsError, - DefaultPaywallNotFoundError, - PaywallLocationNotFound, -} from "../paywall-location.service"; -import { IntegrationHarness } from "../../testing/integration-harness"; -import { createIntegrationTestRunner } from "../../effect/runtimes/integration-test"; -import { generateId } from "@/lib/id/generate"; -import { PaywallRepository } from "@/lib/repositories/paywall.repository"; - -describe.sequential("PaywallLocationService error path", () => { - test("should fail to create paywall location with duplicate slug", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallLocationService = yield* PaywallLocationService; - const paywallRepository = yield* PaywallRepository; - - // Create a test paywall first - const testPaywall = { - id: generateId("paywall"), - projectId: h.resources.project.id, - name: "Test Paywall for Duplicate", - environment: EnvironmentEnum.Production, - createdAt: new Date(), - updatedAt: new Date(), - }; - yield* paywallRepository.createPaywall(testPaywall); - - const input = { - projectId: h.resources.project.id, - name: "Test Paywall Location", - slug: "duplicate-slug", - defaultPaywallId: testPaywall.id, - }; - - // Create first paywall location - yield* paywallLocationService.createPaywallLocation(input); - - // Try to create second paywall location with same slug - const duplicateInput = { - projectId: h.resources.project.id, - name: "Duplicate Paywall Location", - slug: "duplicate-slug", - defaultPaywallId: testPaywall.id, - }; - yield* paywallLocationService.createPaywallLocation(duplicateInput); - return "created"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(SlugAlreadyExistsError); - }); - - test("should fail to create paywall location with non-existent paywall", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentPaywallId = generateId("paywall"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallLocationService = yield* PaywallLocationService; - - const input = { - projectId: h.resources.project.id, - name: "Test Paywall Location", - slug: "test-paywall-location-non-existent-paywall", - defaultPaywallId: nonExistentPaywallId, - }; - yield* paywallLocationService.createPaywallLocation(input); - return "created"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(DefaultPaywallNotFoundError); - }); - - test("should fail to get paywall location by non-existent ID", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("paywallLocation"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallLocationService = yield* PaywallLocationService; - const paywallLocation = - yield* paywallLocationService.getPaywallLocationById( - nonExistentId, - ); - return paywallLocation; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(PaywallLocationNotFound); - }); - - test("should fail to delete non-existent paywall location", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("paywallLocation"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallLocationService = yield* PaywallLocationService; - yield* paywallLocationService.deletePaywallLocation({ - paywallLocationId: nonExistentId, - }); - return "deleted"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(PaywallLocationNotFound); - }); + DefaultPaywallNotFoundError, + PaywallLocationNotFound, + PaywallLocationService, + SlugAlreadyExistsError +} from '../paywall-location.service'; + +describe.sequential('PaywallLocationService error path', () => { + test('should fail to create paywall location with duplicate slug', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + const paywallRepository = yield* PaywallRepository; + + // Create a test paywall first + const testPaywall = { + id: generateId('paywall'), + projectId: h.resources.project.id, + name: 'Test Paywall for Duplicate', + environment: EnvironmentEnum.Production, + createdAt: new Date(), + updatedAt: new Date() + }; + yield* paywallRepository.createPaywall(testPaywall); + + const input = { + projectId: h.resources.project.id, + name: 'Test Paywall Location', + slug: 'duplicate-slug', + defaultPaywallId: testPaywall.id + }; + + // Create first paywall location + yield* paywallLocationService.createPaywallLocation(input); + + // Try to create second paywall location with same slug + const duplicateInput = { + projectId: h.resources.project.id, + name: 'Duplicate Paywall Location', + slug: 'duplicate-slug', + defaultPaywallId: testPaywall.id + }; + yield* paywallLocationService.createPaywallLocation(duplicateInput); + return 'created'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(SlugAlreadyExistsError); + }); + + test('should fail to create paywall location with non-existent paywall', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentPaywallId = generateId('paywall'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + + const input = { + projectId: h.resources.project.id, + name: 'Test Paywall Location', + slug: 'test-paywall-location-non-existent-paywall', + defaultPaywallId: nonExistentPaywallId + }; + yield* paywallLocationService.createPaywallLocation(input); + return 'created'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(DefaultPaywallNotFoundError); + }); + + test('should fail to get paywall location by non-existent ID', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('paywallLocation'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + const paywallLocation = + yield* paywallLocationService.getPaywallLocationById( + nonExistentId + ); + return paywallLocation; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(PaywallLocationNotFound); + }); + + test('should fail to delete non-existent paywall location', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('paywallLocation'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + yield* paywallLocationService.deletePaywallLocation({ + paywallLocationId: nonExistentId + }); + return 'deleted'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(PaywallLocationNotFound); + }); }); diff --git a/apps/web/lib/services/tests/paywall-location.service.happy.integration.test.ts b/apps/web/lib/services/tests/paywall-location.service.happy.integration.test.ts index b4e4c31a3..df4eeec98 100644 --- a/apps/web/lib/services/tests/paywall-location.service.happy.integration.test.ts +++ b/apps/web/lib/services/tests/paywall-location.service.happy.integration.test.ts @@ -1,332 +1,332 @@ -import { describe, expect, test } from "vitest"; -import { Effect, Exit, pipe } from "effect"; -import { AuthSession } from "../auth.service"; -import { createMockEnvironment } from "../../testing/__mocks__/environment.mock"; -import { Environment } from "../environment.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/constants"; -import { PaywallLocationService } from "../paywall-location.service"; -import { IntegrationHarness } from "../../testing/integration-harness"; -import { createIntegrationTestRunner } from "../../effect/runtimes/integration-test"; -import { PaywallRepository } from "@/lib/repositories/paywall.repository"; -import { generateId } from "@/lib/id/generate"; -import { paywallLocations, eq } from "@voidhash/db"; -import { PaywallLocationRepository } from "@/lib/repositories/paywall-location.repository"; - -describe.sequential("PaywallLocationService happy path", () => { - test("should create a paywall location successfully", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallLocationService = yield* PaywallLocationService; - const paywallRepository = yield* PaywallRepository; - - // Create a test paywall first - const testPaywall = { - id: generateId("paywall"), - projectId: h.resources.project.id, - name: "Test Paywall", - environment: EnvironmentEnum.Production, - createdAt: new Date(), - updatedAt: new Date(), - }; - yield* paywallRepository.createPaywall(testPaywall); - - const input = { - projectId: h.resources.project.id, - name: "Test Paywall Location", - slug: "test-paywall-location", - defaultPaywallId: testPaywall.id, - }; - const paywallLocation = - yield* paywallLocationService.createPaywallLocation(input); - return { paywallLocation, testPaywall }; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - expect(value.paywallLocation).toMatchObject({ - id: expect.any(String), - }); - - t.onTestFinished(async () => { - if (value?.paywallLocation?.id) { - await h.db.primary - .delete(paywallLocations) - .where(eq(paywallLocations.id, value.paywallLocation.id)); - } - if (value?.testPaywall?.id) { - // Note: Paywall cleanup would need to be handled separately - } - }); - }); - - test("should get paywall locations for a project", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const testLocationId = generateId("test"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallLocationService = yield* PaywallLocationService; - const paywallRepository = yield* PaywallRepository; - const paywallLocationRepository = yield* PaywallLocationRepository; - - // Create a test paywall first - const testPaywall = { - id: generateId("test"), - projectId: h.resources.project.id, - name: "Test Paywall for Locations", - environment: EnvironmentEnum.Production, - createdAt: new Date(), - updatedAt: new Date(), - }; - yield* paywallRepository.createPaywall(testPaywall); - - // Create a test paywall location - const testPaywallLocation = { - id: testLocationId, - projectId: h.resources.project.id, - name: "Test Paywall Location for List", - slug: "test-paywall-location-for-list", - environment: EnvironmentEnum.Production, - defaultPaywallId: testPaywall.id, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const testPaywallLocationDifferentProject = { - id: generateId("test"), - projectId: generateId("test"), - name: "Test Paywall Location for List", - slug: "test-paywall-location-for-list", - environment: EnvironmentEnum.Production, - defaultPaywallId: testPaywall.id, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const testPaywallLocationDifferentEnvironment = { - id: generateId("test"), - projectId: h.resources.project.id, - name: "Test Paywall Location for List", - slug: "test-paywall-location-for-list", - environment: EnvironmentEnum.Testing, - defaultPaywallId: testPaywall.id, - createdAt: new Date(), - updatedAt: new Date(), - }; - - yield* paywallLocationRepository.createPaywallLocation( - testPaywallLocationDifferentProject, - ); - yield* paywallLocationRepository.createPaywallLocation( - testPaywallLocationDifferentEnvironment, - ); - - yield* paywallLocationRepository.createPaywallLocation( - testPaywallLocation, - ); - - const paywallLocations = - yield* paywallLocationService.getPaywallLocations( - h.resources.project.id, - ); - - return { paywallLocations }; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(value.paywallLocations.length).toBe(1); - const testLocation = value.paywallLocations.find( - (loc) => loc.slug === "test-paywall-location-for-list", - ); - expect(testLocation).toMatchObject({ - projectId: h.resources.project.id, - name: "Test Paywall Location for List", - slug: "test-paywall-location-for-list", - }); - - t.onTestFinished(async () => { - await h.db.primary - .delete(paywallLocations) - .where(eq(paywallLocations.slug, "test-paywall-location-for-list")); - }); - }); - - test("should get paywall location by ID", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallLocationService = yield* PaywallLocationService; - const paywallRepository = yield* PaywallRepository; - const paywallLocationRepository = yield* PaywallLocationRepository; - - // Create a test paywall first - const testPaywall = { - id: generateId("paywall"), - projectId: h.resources.project.id, - name: "Test Paywall for By ID", - environment: EnvironmentEnum.Production, - createdAt: new Date(), - updatedAt: new Date(), - }; - yield* paywallRepository.createPaywall(testPaywall); - - // Create a test paywall location - const testPaywallLocation = { - id: generateId("paywallLocation"), - projectId: h.resources.project.id, - name: "Test Paywall Location By ID", - slug: "test-paywall-location-by-id", - environment: EnvironmentEnum.Production, - defaultPaywallId: testPaywall.id, - createdAt: new Date(), - updatedAt: new Date(), - }; - yield* paywallLocationRepository.createPaywallLocation( - testPaywallLocation, - ); - - const paywallLocation = - yield* paywallLocationService.getPaywallLocationById( - testPaywallLocation.id, - ); - return { paywallLocation }; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(value.paywallLocation).toMatchObject({ - projectId: h.resources.project.id, - name: "Test Paywall Location By ID", - slug: "test-paywall-location-by-id", - }); - - t.onTestFinished(async () => { - await h.db.primary - .delete(paywallLocations) - .where(eq(paywallLocations.slug, "test-paywall-location-by-id")); - }); - }); - - test("should delete paywall location successfully", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallLocationService = yield* PaywallLocationService; - const paywallRepository = yield* PaywallRepository; - const paywallLocationRepository = yield* PaywallLocationRepository; - - // Create a test paywall first - const testPaywall = { - id: generateId("test"), - projectId: h.resources.project.id, - name: "Test Paywall for Delete", - environment: EnvironmentEnum.Production, - createdAt: new Date(), - updatedAt: new Date(), - }; - yield* paywallRepository.createPaywall(testPaywall); - - // Create a test paywall location - const testPaywallLocation = { - id: generateId("test"), - projectId: h.resources.project.id, - name: "Test Paywall Location for Delete", - slug: "test-paywall-location-for-delete", - environment: EnvironmentEnum.Production, - defaultPaywallId: testPaywall.id, - createdAt: new Date(), - updatedAt: new Date(), - }; - yield* paywallLocationRepository.createPaywallLocation( - testPaywallLocation, - ); - - yield* paywallLocationService.deletePaywallLocation({ - paywallLocationId: testPaywallLocation.id, - }); - return "deleted"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - - expect(value).toBe("deleted"); - - t.onTestFinished(async () => { - await h.db.primary - .delete(paywallLocations) - .where(eq(paywallLocations.slug, "test-paywall-location-for-delete")); - }); - }); +import { eq, paywallLocations } from '@voidhash/db'; +import { Environment as EnvironmentEnum } from '@voidhash/lib/constants'; +import { Effect, Exit, pipe } from 'effect'; +import { describe, expect, test } from 'vitest'; +import { generateId } from '@/lib/id/generate'; +import { PaywallRepository } from '@/lib/repositories/paywall.repository'; +import { PaywallLocationRepository } from '@/lib/repositories/paywall-location.repository'; +import { createIntegrationTestRunner } from '../../effect/runtimes/integration-test'; +import { createMockEnvironment } from '../../testing/__mocks__/environment.mock'; +import { IntegrationHarness } from '../../testing/integration-harness'; +import { AuthSession } from '../auth.service'; +import { Environment } from '../environment.service'; +import { PaywallLocationService } from '../paywall-location.service'; + +describe.sequential('PaywallLocationService happy path', () => { + test('should create a paywall location successfully', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + const paywallRepository = yield* PaywallRepository; + + // Create a test paywall first + const testPaywall = { + id: generateId('paywall'), + projectId: h.resources.project.id, + name: 'Test Paywall', + environment: EnvironmentEnum.Production, + createdAt: new Date(), + updatedAt: new Date() + }; + yield* paywallRepository.createPaywall(testPaywall); + + const input = { + projectId: h.resources.project.id, + name: 'Test Paywall Location', + slug: 'test-paywall-location', + defaultPaywallId: testPaywall.id + }; + const paywallLocation = + yield* paywallLocationService.createPaywallLocation(input); + return { paywallLocation, testPaywall }; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + expect(value.paywallLocation).toMatchObject({ + id: expect.any(String) + }); + + t.onTestFinished(async () => { + if (value?.paywallLocation?.id) { + await h.db.primary + .delete(paywallLocations) + .where(eq(paywallLocations.id, value.paywallLocation.id)); + } + if (value?.testPaywall?.id) { + // Note: Paywall cleanup would need to be handled separately + } + }); + }); + + test('should get paywall locations for a project', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const testLocationId = generateId('test'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + const paywallRepository = yield* PaywallRepository; + const paywallLocationRepository = yield* PaywallLocationRepository; + + // Create a test paywall first + const testPaywall = { + id: generateId('test'), + projectId: h.resources.project.id, + name: 'Test Paywall for Locations', + environment: EnvironmentEnum.Production, + createdAt: new Date(), + updatedAt: new Date() + }; + yield* paywallRepository.createPaywall(testPaywall); + + // Create a test paywall location + const testPaywallLocation = { + id: testLocationId, + projectId: h.resources.project.id, + name: 'Test Paywall Location for List', + slug: 'test-paywall-location-for-list', + environment: EnvironmentEnum.Production, + defaultPaywallId: testPaywall.id, + createdAt: new Date(), + updatedAt: new Date() + }; + + const testPaywallLocationDifferentProject = { + id: generateId('test'), + projectId: generateId('test'), + name: 'Test Paywall Location for List', + slug: 'test-paywall-location-for-list', + environment: EnvironmentEnum.Production, + defaultPaywallId: testPaywall.id, + createdAt: new Date(), + updatedAt: new Date() + }; + + const testPaywallLocationDifferentEnvironment = { + id: generateId('test'), + projectId: h.resources.project.id, + name: 'Test Paywall Location for List', + slug: 'test-paywall-location-for-list', + environment: EnvironmentEnum.Testing, + defaultPaywallId: testPaywall.id, + createdAt: new Date(), + updatedAt: new Date() + }; + + yield* paywallLocationRepository.createPaywallLocation( + testPaywallLocationDifferentProject + ); + yield* paywallLocationRepository.createPaywallLocation( + testPaywallLocationDifferentEnvironment + ); + + yield* paywallLocationRepository.createPaywallLocation( + testPaywallLocation + ); + + const paywallLocations = + yield* paywallLocationService.getPaywallLocations( + h.resources.project.id + ); + + return { paywallLocations }; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(value.paywallLocations.length).toBe(1); + const testLocation = value.paywallLocations.find( + (loc) => loc.slug === 'test-paywall-location-for-list' + ); + expect(testLocation).toMatchObject({ + projectId: h.resources.project.id, + name: 'Test Paywall Location for List', + slug: 'test-paywall-location-for-list' + }); + + t.onTestFinished(async () => { + await h.db.primary + .delete(paywallLocations) + .where(eq(paywallLocations.slug, 'test-paywall-location-for-list')); + }); + }); + + test('should get paywall location by ID', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + const paywallRepository = yield* PaywallRepository; + const paywallLocationRepository = yield* PaywallLocationRepository; + + // Create a test paywall first + const testPaywall = { + id: generateId('paywall'), + projectId: h.resources.project.id, + name: 'Test Paywall for By ID', + environment: EnvironmentEnum.Production, + createdAt: new Date(), + updatedAt: new Date() + }; + yield* paywallRepository.createPaywall(testPaywall); + + // Create a test paywall location + const testPaywallLocation = { + id: generateId('paywallLocation'), + projectId: h.resources.project.id, + name: 'Test Paywall Location By ID', + slug: 'test-paywall-location-by-id', + environment: EnvironmentEnum.Production, + defaultPaywallId: testPaywall.id, + createdAt: new Date(), + updatedAt: new Date() + }; + yield* paywallLocationRepository.createPaywallLocation( + testPaywallLocation + ); + + const paywallLocation = + yield* paywallLocationService.getPaywallLocationById( + testPaywallLocation.id + ); + return { paywallLocation }; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(value.paywallLocation).toMatchObject({ + projectId: h.resources.project.id, + name: 'Test Paywall Location By ID', + slug: 'test-paywall-location-by-id' + }); + + t.onTestFinished(async () => { + await h.db.primary + .delete(paywallLocations) + .where(eq(paywallLocations.slug, 'test-paywall-location-by-id')); + }); + }); + + test('should delete paywall location successfully', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallLocationService = yield* PaywallLocationService; + const paywallRepository = yield* PaywallRepository; + const paywallLocationRepository = yield* PaywallLocationRepository; + + // Create a test paywall first + const testPaywall = { + id: generateId('test'), + projectId: h.resources.project.id, + name: 'Test Paywall for Delete', + environment: EnvironmentEnum.Production, + createdAt: new Date(), + updatedAt: new Date() + }; + yield* paywallRepository.createPaywall(testPaywall); + + // Create a test paywall location + const testPaywallLocation = { + id: generateId('test'), + projectId: h.resources.project.id, + name: 'Test Paywall Location for Delete', + slug: 'test-paywall-location-for-delete', + environment: EnvironmentEnum.Production, + defaultPaywallId: testPaywall.id, + createdAt: new Date(), + updatedAt: new Date() + }; + yield* paywallLocationRepository.createPaywallLocation( + testPaywallLocation + ); + + yield* paywallLocationService.deletePaywallLocation({ + paywallLocationId: testPaywallLocation.id + }); + return 'deleted'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + + expect(value).toBe('deleted'); + + t.onTestFinished(async () => { + await h.db.primary + .delete(paywallLocations) + .where(eq(paywallLocations.slug, 'test-paywall-location-for-delete')); + }); + }); }); diff --git a/apps/web/lib/services/tests/paywall.service.error.integration.test.ts b/apps/web/lib/services/tests/paywall.service.error.integration.test.ts index b0bbacbb5..2f328c386 100644 --- a/apps/web/lib/services/tests/paywall.service.error.integration.test.ts +++ b/apps/web/lib/services/tests/paywall.service.error.integration.test.ts @@ -1,174 +1,174 @@ -import { describe, expect, test } from "vitest"; -import { Cause, Effect, Exit, pipe } from "effect"; -import { AuthSession } from "../auth.service"; -import { createMockEnvironment } from "../../testing/__mocks__/environment.mock"; -import { Environment } from "../environment.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/constants"; +import { eq, paywallLocations, paywalls } from '@voidhash/db'; +import { Environment as EnvironmentEnum } from '@voidhash/lib/constants'; +import { Cause, Effect, Exit, pipe } from 'effect'; +import { describe, expect, test } from 'vitest'; +import { generateId } from '@/lib/id/generate'; +import { createIntegrationTestRunner } from '../../effect/runtimes/integration-test'; +import { createMockEnvironment } from '../../testing/__mocks__/environment.mock'; +import { IntegrationHarness } from '../../testing/integration-harness'; +import { AuthSession } from '../auth.service'; +import { Environment } from '../environment.service'; import { - PaywallService, - PaywallNotFoundError, - PaywallInUseError, -} from "../paywall.service"; -import { IntegrationHarness } from "../../testing/integration-harness"; -import { createIntegrationTestRunner } from "../../effect/runtimes/integration-test"; -import { generateId } from "@/lib/id/generate"; -import { PaywallLocationService } from "../paywall-location.service"; -import { paywallLocations, paywalls, eq } from "@voidhash/db"; - -describe.sequential("PaywallService error path", () => { - test("should fail to get paywall by non-existent ID", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("paywall"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; - const paywall = yield* paywallService.getPaywallById(nonExistentId); - return paywall; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(PaywallNotFoundError); - }); - - test("should fail to update non-existent paywall", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("paywall"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; - yield* paywallService.updatePaywall({ - paywallId: nonExistentId, - name: "Updated Name", - paywallProducts: [], - }); - return "updated"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(PaywallNotFoundError); - }); - - test("should fail to delete non-existent paywall", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const nonExistentId = generateId("paywall"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; - yield* paywallService.deletePaywall({ - paywallId: nonExistentId, - }); - return "deleted"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(PaywallNotFoundError); - }); - - test("should fail to delete paywall that is in use", async (t) => { - const h = await IntegrationHarness.init(t); - - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; - const paywallLocationService = yield* PaywallLocationService; - - // Create a paywall - const paywall = yield* paywallService.createPaywall({ - projectId: h.resources.project.id, - name: "Test Paywall for Delete", - }); - - // Create a paywall location that uses this paywall - yield* paywallLocationService.createPaywallLocation({ - projectId: h.resources.project.id, - name: "Test Location", - slug: "test-location", - defaultPaywallId: paywall.id, - }); - - // Try to delete the paywall (should fail because it's in use) - yield* paywallService.deletePaywall({ - paywallId: paywall.id, - }); - return "deleted"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); - - expect(Exit.isFailure(result)).toBe(true); - const error = Exit.getOrElse(result, (e) => Cause.squash(e)); - expect(error).toBeInstanceOf(PaywallInUseError); - - // Clean up the created resources - t.onTestFinished(async () => { - // Clean up paywall location first - await h.db.primary - .delete(paywallLocations) - .where(eq(paywallLocations.slug, "test-location")); - // Then clean up paywall - await h.db.primary - .delete(paywalls) - .where(eq(paywalls.name, "Test Paywall for Delete")); - }); - }); + PaywallInUseError, + PaywallNotFoundError, + PaywallService +} from '../paywall.service'; +import { PaywallLocationService } from '../paywall-location.service'; + +describe.sequential('PaywallService error path', () => { + test('should fail to get paywall by non-existent ID', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('paywall'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + const paywall = yield* paywallService.getPaywallById(nonExistentId); + return paywall; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(PaywallNotFoundError); + }); + + test('should fail to update non-existent paywall', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('paywall'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + yield* paywallService.updatePaywall({ + paywallId: nonExistentId, + name: 'Updated Name', + paywallProducts: [] + }); + return 'updated'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(PaywallNotFoundError); + }); + + test('should fail to delete non-existent paywall', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const nonExistentId = generateId('paywall'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + yield* paywallService.deletePaywall({ + paywallId: nonExistentId + }); + return 'deleted'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(PaywallNotFoundError); + }); + + test('should fail to delete paywall that is in use', async (t) => { + const h = await IntegrationHarness.init(t); + + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + const paywallLocationService = yield* PaywallLocationService; + + // Create a paywall + const paywall = yield* paywallService.createPaywall({ + projectId: h.resources.project.id, + name: 'Test Paywall for Delete' + }); + + // Create a paywall location that uses this paywall + yield* paywallLocationService.createPaywallLocation({ + projectId: h.resources.project.id, + name: 'Test Location', + slug: 'test-location', + defaultPaywallId: paywall.id + }); + + // Try to delete the paywall (should fail because it's in use) + yield* paywallService.deletePaywall({ + paywallId: paywall.id + }); + return 'deleted'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); + + expect(Exit.isFailure(result)).toBe(true); + const error = Exit.getOrElse(result, (e) => Cause.squash(e)); + expect(error).toBeInstanceOf(PaywallInUseError); + + // Clean up the created resources + t.onTestFinished(async () => { + // Clean up paywall location first + await h.db.primary + .delete(paywallLocations) + .where(eq(paywallLocations.slug, 'test-location')); + // Then clean up paywall + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.name, 'Test Paywall for Delete')); + }); + }); }); diff --git a/apps/web/lib/services/tests/paywall.service.happy.integration.test.ts b/apps/web/lib/services/tests/paywall.service.happy.integration.test.ts index d1dac7fa0..dab9c7bab 100644 --- a/apps/web/lib/services/tests/paywall.service.happy.integration.test.ts +++ b/apps/web/lib/services/tests/paywall.service.happy.integration.test.ts @@ -1,281 +1,281 @@ -import { describe, expect, test } from "vitest"; -import { Effect, Exit, pipe } from "effect"; -import { AuthSession } from "../auth.service"; -import { createMockEnvironment } from "../../testing/__mocks__/environment.mock"; -import { Environment } from "../environment.service"; -import { Environment as EnvironmentEnum } from "@voidhash/lib/constants"; -import { PaywallService } from "../paywall.service"; -import { IntegrationHarness } from "../../testing/integration-harness"; -import { createIntegrationTestRunner } from "../../effect/runtimes/integration-test"; -import { generateId } from "@/lib/id/generate"; -import { paywalls, eq } from "@voidhash/db"; +import { eq, paywalls } from '@voidhash/db'; +import { Environment as EnvironmentEnum } from '@voidhash/lib/constants'; +import { Effect, Exit, pipe } from 'effect'; +import { describe, expect, test } from 'vitest'; +import { generateId } from '@/lib/id/generate'; +import { createIntegrationTestRunner } from '../../effect/runtimes/integration-test'; +import { createMockEnvironment } from '../../testing/__mocks__/environment.mock'; +import { IntegrationHarness } from '../../testing/integration-harness'; +import { AuthSession } from '../auth.service'; +import { Environment } from '../environment.service'; +import { PaywallService } from '../paywall.service'; -describe.sequential("PaywallService happy path", () => { - test("should create a paywall successfully", async (t) => { - const h = await IntegrationHarness.init(t); +describe.sequential('PaywallService happy path', () => { + test('should create a paywall successfully', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const input = { - projectId: h.resources.project.id, - name: "Test Paywall", - }; - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; - const paywall = yield* paywallService.createPaywall(input); - return paywall; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const integrationTestRunner = createIntegrationTestRunner('hono'); + const input = { + projectId: h.resources.project.id, + name: 'Test Paywall' + }; + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; + const paywall = yield* paywallService.createPaywall(input); + return paywall; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); - expect(value).toMatchObject({ - id: expect.any(String), - }); + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); + expect(value).toMatchObject({ + id: expect.any(String) + }); - t.onTestFinished(async () => { - if (value?.id) { - await h.db.primary.delete(paywalls).where(eq(paywalls.id, value.id)); - } - }); - }); + t.onTestFinished(async () => { + if (value?.id) { + await h.db.primary.delete(paywalls).where(eq(paywalls.id, value.id)); + } + }); + }); - test("should get paywalls for a project", async (t) => { - const h = await IntegrationHarness.init(t); + test('should get paywalls for a project', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; - // Create a test paywall - const testPaywall = { - id: generateId("paywall"), - projectId: h.resources.project.id, - name: "Test Paywall for List", - environment: EnvironmentEnum.Production, - createdAt: new Date(), - updatedAt: new Date(), - }; - yield* paywallService.createPaywall({ - projectId: testPaywall.projectId, - name: testPaywall.name, - }); + // Create a test paywall + const testPaywall = { + id: generateId('paywall'), + projectId: h.resources.project.id, + name: 'Test Paywall for List', + environment: EnvironmentEnum.Production, + createdAt: new Date(), + updatedAt: new Date() + }; + yield* paywallService.createPaywall({ + projectId: testPaywall.projectId, + name: testPaywall.name + }); - const paywalls = yield* paywallService.getPaywalls( - h.resources.project.id, - ); - return paywalls; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const paywalls = yield* paywallService.getPaywalls( + h.resources.project.id + ); + return paywalls; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); - expect(value.length).toBeGreaterThan(0); - const testPaywall = value.find((p) => p.name === "Test Paywall for List"); - expect(testPaywall).toMatchObject({ - projectId: h.resources.project.id, - name: "Test Paywall for List", - }); + expect(value.length).toBeGreaterThan(0); + const testPaywall = value.find((p) => p.name === 'Test Paywall for List'); + expect(testPaywall).toMatchObject({ + projectId: h.resources.project.id, + name: 'Test Paywall for List' + }); - t.onTestFinished(async () => { - await h.db.primary - .delete(paywalls) - .where(eq(paywalls.name, "Test Paywall for List")); - }); - }); + t.onTestFinished(async () => { + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.name, 'Test Paywall for List')); + }); + }); - test("should get paywall by ID", async (t) => { - const h = await IntegrationHarness.init(t); + test('should get paywall by ID', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; - // Create a test paywall - const testPaywall = { - id: generateId("paywall"), - projectId: h.resources.project.id, - name: "Test Paywall By ID", - environment: EnvironmentEnum.Production, - createdAt: new Date(), - updatedAt: new Date(), - }; - const createdPaywall = yield* paywallService.createPaywall({ - projectId: testPaywall.projectId, - name: testPaywall.name, - }); + // Create a test paywall + const testPaywall = { + id: generateId('paywall'), + projectId: h.resources.project.id, + name: 'Test Paywall By ID', + environment: EnvironmentEnum.Production, + createdAt: new Date(), + updatedAt: new Date() + }; + const createdPaywall = yield* paywallService.createPaywall({ + projectId: testPaywall.projectId, + name: testPaywall.name + }); - const paywall = yield* paywallService.getPaywallById( - createdPaywall.id, - ); - return paywall; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const paywall = yield* paywallService.getPaywallById( + createdPaywall.id + ); + return paywall; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); - expect(value).toMatchObject({ - projectId: h.resources.project.id, - name: "Test Paywall By ID", - }); + expect(value).toMatchObject({ + projectId: h.resources.project.id, + name: 'Test Paywall By ID' + }); - t.onTestFinished(async () => { - await h.db.primary - .delete(paywalls) - .where(eq(paywalls.name, "Test Paywall By ID")); - }); - }); + t.onTestFinished(async () => { + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.name, 'Test Paywall By ID')); + }); + }); - test("should update paywall successfully", async (t) => { - const h = await IntegrationHarness.init(t); + test('should update paywall successfully', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; - // Create a test paywall - const testPaywall = { - id: generateId("paywall"), - projectId: h.resources.project.id, - name: "Test Paywall for Update", - environment: EnvironmentEnum.Production, - createdAt: new Date(), - updatedAt: new Date(), - }; - const createdPaywall = yield* paywallService.createPaywall({ - projectId: testPaywall.projectId, - name: testPaywall.name, - }); + // Create a test paywall + const testPaywall = { + id: generateId('paywall'), + projectId: h.resources.project.id, + name: 'Test Paywall for Update', + environment: EnvironmentEnum.Production, + createdAt: new Date(), + updatedAt: new Date() + }; + const createdPaywall = yield* paywallService.createPaywall({ + projectId: testPaywall.projectId, + name: testPaywall.name + }); - const input = { - paywallId: createdPaywall.id, - name: "Updated Paywall Name", - paywallProducts: [], - }; - yield* paywallService.updatePaywall(input); - return "updated"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + const input = { + paywallId: createdPaywall.id, + name: 'Updated Paywall Name', + paywallProducts: [] + }; + yield* paywallService.updatePaywall(input); + return 'updated'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); - expect(value).toBe("updated"); + expect(value).toBe('updated'); - t.onTestFinished(async () => { - await h.db.primary - .delete(paywalls) - .where(eq(paywalls.name, "Updated Paywall Name")); - }); - }); + t.onTestFinished(async () => { + await h.db.primary + .delete(paywalls) + .where(eq(paywalls.name, 'Updated Paywall Name')); + }); + }); - test("should delete paywall successfully", async (t) => { - const h = await IntegrationHarness.init(t); + test('should delete paywall successfully', async (t) => { + const h = await IntegrationHarness.init(t); - const integrationTestRunner = createIntegrationTestRunner("hono"); - const result = await integrationTestRunner( - Effect.gen(function* () { - return yield* pipe( - Effect.gen(function* () { - const paywallService = yield* PaywallService; + const integrationTestRunner = createIntegrationTestRunner('hono'); + const result = await integrationTestRunner( + Effect.gen(function* () { + return yield* pipe( + Effect.gen(function* () { + const paywallService = yield* PaywallService; - // Create a test paywall - const testPaywall = { - id: generateId("paywall"), - projectId: h.resources.project.id, - name: "Test Paywall for Delete", - environment: EnvironmentEnum.Production, - createdAt: new Date(), - updatedAt: new Date(), - }; - const createdPaywall = yield* paywallService.createPaywall({ - projectId: testPaywall.projectId, - name: testPaywall.name, - }); + // Create a test paywall + const testPaywall = { + id: generateId('paywall'), + projectId: h.resources.project.id, + name: 'Test Paywall for Delete', + environment: EnvironmentEnum.Production, + createdAt: new Date(), + updatedAt: new Date() + }; + const createdPaywall = yield* paywallService.createPaywall({ + projectId: testPaywall.projectId, + name: testPaywall.name + }); - yield* paywallService.deletePaywall({ - paywallId: createdPaywall.id, - }); - return "deleted"; - }), - Effect.provideService( - AuthSession, - h.createAuthSession({ type: "user" }), - ), - Effect.provideService( - Environment, - createMockEnvironment(EnvironmentEnum.Production), - ), - ); - }), - ); + yield* paywallService.deletePaywall({ + paywallId: createdPaywall.id + }); + return 'deleted'; + }), + Effect.provideService( + AuthSession, + h.createAuthSession({ type: 'user' }) + ), + Effect.provideService( + Environment, + createMockEnvironment(EnvironmentEnum.Production) + ) + ); + }) + ); - expect(Exit.isSuccess(result)).toBe(true); - const value = Exit.getOrElse(result, (e) => { - throw e; - }); + expect(Exit.isSuccess(result)).toBe(true); + const value = Exit.getOrElse(result, (e) => { + throw e; + }); - expect(value).toBe("deleted"); - }); + expect(value).toBe('deleted'); + }); }); diff --git a/apps/web/lib/services/user.service.ts b/apps/web/lib/services/user.service.ts index 862a52218..1e42c4749 100644 --- a/apps/web/lib/services/user.service.ts +++ b/apps/web/lib/services/user.service.ts @@ -1,46 +1,46 @@ -import { Effect } from "effect"; -import { AuthSession } from "@/lib/services/auth.service"; -import { BetterAuth } from "@/lib/effect/better-auth"; -import { Request } from "@/lib/effect/request"; -import { NotFoundError } from "@/lib/effect/errors"; +import { Effect } from 'effect'; +import { BetterAuth } from '@/lib/effect/better-auth'; +import { NotFoundError } from '@/lib/effect/errors'; +import { Request } from '@/lib/effect/request'; +import { AuthSession } from '@/lib/services/auth.service'; -export class UserService extends Effect.Service()("UserService", { - dependencies: [], - effect: Effect.gen(function* () { - return { - getUser: () => - Effect.gen(function* () { - const session = yield* AuthSession; - const betterAuth = yield* BetterAuth; - const request = yield* Request; +export class UserService extends Effect.Service()('UserService', { + dependencies: [], + effect: Effect.gen(function* () { + return { + getUser: () => + Effect.gen(function* () { + const session = yield* AuthSession; + const betterAuth = yield* BetterAuth; + const request = yield* Request; - const headers = yield* request.getHeaders; - const organizations = yield* betterAuth.use(async (client) => - client.api.listOrganizations({ - headers, - }) - ); + const headers = yield* request.getHeaders(); + const organizations = yield* betterAuth.use(async (client) => + client.api.listOrganizations({ + headers + }) + ); - if (!session?.user) { - return yield* Effect.fail( - new NotFoundError({ - message: "User not found", - }) - ); - } + if (!session?.user) { + return yield* Effect.fail( + new NotFoundError({ + message: 'User not found' + }) + ); + } - return { - ...session.user, - organizations: organizations.map((o) => ({ - id: o.id, - name: o.name, - slug: o.slug, - logo: o.logo ?? null, - createdAt: o.createdAt, - metadata: o.metadata ?? null, - })), - }; - }), - }; - }), + return { + ...session.user, + organizations: organizations.map((o) => ({ + id: o.id, + name: o.name, + slug: o.slug, + logo: o.logo ?? null, + createdAt: o.createdAt, + metadata: o.metadata ?? null + })) + }; + }) + }; + }) }) {} diff --git a/apps/web/lib/testing/__mocks__/auth.mock.ts b/apps/web/lib/testing/__mocks__/auth.mock.ts index 3fb776a7b..ddf4ebfbd 100644 --- a/apps/web/lib/testing/__mocks__/auth.mock.ts +++ b/apps/web/lib/testing/__mocks__/auth.mock.ts @@ -1,60 +1,60 @@ -import { ApiKeySession, UserSession } from "@/lib/services/auth.service"; -import { Environment } from "@voidhash/lib/constants"; +import { Environment } from '@voidhash/lib/constants'; +import type { ApiKeySession, UserSession } from '@/lib/services/auth.service'; export const createMockUserAuthSession = ( - overrides: Partial = {} + overrides: Partial = {} ): UserSession => ({ - user: { - id: "user_123", - email: "test@example.com", - name: "Test User", - image: null, - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - }, - customer: null, - organizations: [ - { - id: "test_org_123", - slug: "test-org", - permissions: ["organization:all"], - }, - ], - projects: [ - { - id: "test_proj_123", - slug: "test-project", - organizationId: "test_org_123", - permissions: ["project:all"], - }, - ], - environment: null, - method: "user", - ...overrides, + user: { + id: 'user_123', + email: 'test@example.com', + name: 'Test User', + image: null, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date() + }, + customer: null, + organizations: [ + { + id: 'test_org_123', + slug: 'test-org', + permissions: ['organization:all'] + } + ], + projects: [ + { + id: 'test_proj_123', + slug: 'test-project', + organizationId: 'test_org_123', + permissions: ['project:all'] + } + ], + environment: null, + method: 'user', + ...overrides }); export const createMockSecretApiKeyAuthSession = ( - overrides: Partial = {} + overrides: Partial = {} ): ApiKeySession => ({ - user: null, - customer: null, - organizations: [ - { - id: "test_org_123", - slug: "test-org", - permissions: ["organization:all"], - }, - ], - projects: [ - { - id: "test_proj_123", - slug: "test-project", - organizationId: "test_org_123", - permissions: ["project:all"], - }, - ], - environment: Environment.Production, - method: "api-key", - ...overrides, + user: null, + customer: null, + organizations: [ + { + id: 'test_org_123', + slug: 'test-org', + permissions: ['organization:all'] + } + ], + projects: [ + { + id: 'test_proj_123', + slug: 'test-project', + organizationId: 'test_org_123', + permissions: ['project:all'] + } + ], + environment: Environment.Production, + method: 'api-key', + ...overrides }); diff --git a/apps/web/lib/testing/__mocks__/db.mock.ts b/apps/web/lib/testing/__mocks__/db.mock.ts index 6c4d7a4de..e8c87836f 100644 --- a/apps/web/lib/testing/__mocks__/db.mock.ts +++ b/apps/web/lib/testing/__mocks__/db.mock.ts @@ -1,90 +1,98 @@ -import { Effect, Context, Data } from "effect"; -import { vi } from "vitest"; +import { Context, Data, Effect } from 'effect'; +import { vi } from 'vitest'; // Mock the database error class -export class DatabaseError extends Data.TaggedError("DatabaseError")<{ - readonly cause?: unknown; - readonly message: string; +export class DatabaseError extends Data.TaggedError('DatabaseError')<{ + readonly cause?: unknown; + readonly message: string; }> {} // Mock transaction context type TransactionContextShape = ( - fn: (client: unknown) => Promise + fn: (client: unknown) => Promise ) => Effect.Effect; -export class TransactionContext extends Context.Tag("TransactionContext")< - TransactionContext, - TransactionContextShape +export class TransactionContext extends Context.Tag('TransactionContext')< + TransactionContext, + TransactionContextShape >() { - public static readonly provide = ( - transaction: TransactionContextShape - ): (( - self: Effect.Effect - ) => Effect.Effect>) => - Effect.provideService(this, transaction); + static readonly provide = ( + transaction: TransactionContextShape + ): (( + self: Effect.Effect + ) => Effect.Effect>) => + Effect.provideService(this, transaction); } // Mock database client export const mockDb = { - transaction: vi.fn(), - // Add other database methods as needed for your tests + transaction: vi.fn() + // Add other database methods as needed for your tests }; // Mock Db service export const Db = Effect.Service<{ - use: (fn: (client: unknown) => Promise) => Effect.Effect; - makeQuery: ( - queryFn: (execute: unknown, input: Input) => Effect.Effect - ) => (...args: [Input] extends [never] ? [] : [input: Input]) => Effect.Effect; - transaction: ( - txExecute: (tx: TransactionContextShape) => Effect.Effect - ) => Effect.Effect; -}>()("app/Db", { - dependencies: [], - effect: Effect.succeed({ - use: vi.fn().mockImplementation((fn: (client: unknown) => Promise) => - Effect.tryPromise({ - try: () => fn(mockDb), - catch: (cause) => new DatabaseError({ - message: "Mock database error", - cause: cause, - }), - }) - ), - makeQuery: vi.fn().mockImplementation(() => () => { - return Effect.succeed({}); // Mock successful result - }), - transaction: vi.fn().mockImplementation(() => { - return Effect.succeed({}); // Mock successful transaction - }), - }), + use: ( + fn: (client: unknown) => Promise + ) => Effect.Effect; + makeQuery: ( + queryFn: (execute: unknown, input: Input) => Effect.Effect + ) => ( + ...args: [Input] extends [never] ? [] : [input: Input] + ) => Effect.Effect; + transaction: ( + txExecute: (tx: TransactionContextShape) => Effect.Effect + ) => Effect.Effect; +}>()('app/Db', { + dependencies: [], + effect: Effect.succeed({ + use: vi + .fn() + .mockImplementation((fn: (client: unknown) => Promise) => + Effect.tryPromise({ + try: () => fn(mockDb), + catch: (cause) => + new DatabaseError({ + message: 'Mock database error', + cause + }) + }) + ), + makeQuery: vi.fn().mockImplementation(() => () => { + return Effect.succeed({}); // Mock successful result + }), + transaction: vi.fn().mockImplementation(() => { + return Effect.succeed({}); // Mock successful transaction + }) + }) }); // Export mock utilities for testing export const createMockDb = () => ({ - use: vi.fn().mockImplementation((fn: (client: unknown) => Promise) => - Effect.tryPromise({ - try: () => fn(mockDb), - catch: (cause) => new DatabaseError({ - message: "Mock database error", - cause: cause, - }), - }) - ), - makeQuery: vi.fn().mockImplementation(() => () => { - return Effect.succeed({}); - }), - transaction: vi.fn().mockImplementation(() => { - return Effect.succeed({}); - }), + use: vi.fn().mockImplementation((fn: (client: unknown) => Promise) => + Effect.tryPromise({ + try: () => fn(mockDb), + catch: (cause) => + new DatabaseError({ + message: 'Mock database error', + cause + }) + }) + ), + makeQuery: vi.fn().mockImplementation(() => () => { + return Effect.succeed({}); + }), + transaction: vi.fn().mockImplementation(() => { + return Effect.succeed({}); + }) }); // Mock transaction export const mockTransaction = { - // Add transaction methods as needed + // Add transaction methods as needed }; // Reset all mocks export const resetDbMocks = () => { - vi.clearAllMocks(); + vi.clearAllMocks(); }; diff --git a/apps/web/lib/testing/__mocks__/environment.mock.ts b/apps/web/lib/testing/__mocks__/environment.mock.ts index 6b83e6856..ad379472e 100644 --- a/apps/web/lib/testing/__mocks__/environment.mock.ts +++ b/apps/web/lib/testing/__mocks__/environment.mock.ts @@ -1,4 +1,5 @@ -import { Environment, EnvironmentValue } from "@voidhash/lib/constants"; +import { Environment, type EnvironmentValue } from '@voidhash/lib/constants'; -export const createMockEnvironment = (environment: EnvironmentValue = Environment.Testing) => - environment; +export const createMockEnvironment = ( + environment: EnvironmentValue = Environment.Testing +) => environment; diff --git a/apps/web/lib/testing/__mocks__/repositories/api-key.repository.mock.ts b/apps/web/lib/testing/__mocks__/repositories/api-key.repository.mock.ts index 244168638..384101bfb 100644 --- a/apps/web/lib/testing/__mocks__/repositories/api-key.repository.mock.ts +++ b/apps/web/lib/testing/__mocks__/repositories/api-key.repository.mock.ts @@ -1,59 +1,59 @@ -import { vi } from "vitest"; -import { ApiKeyRepository } from "@/lib/repositories/api-key.repository"; +import { vi } from 'vitest'; +import { ApiKeyRepository } from '@/lib/repositories/api-key.repository'; const defaultMock = { - createApiKey: vi.fn(), - getApiKeyById: vi.fn(), - getApiKeys: vi.fn(), - updateApiKey: vi.fn(), - deleteApiKey: vi.fn(), + createApiKey: vi.fn(), + getApiKeyById: vi.fn(), + getApiKeys: vi.fn(), + updateApiKey: vi.fn(), + deleteApiKey: vi.fn() }; export const createMockApiKeyRepository = ( - mockDefinition: typeof defaultMock = defaultMock + mockDefinition: typeof defaultMock = defaultMock ) => { - const mockApiKeyRepository = new ApiKeyRepository(mockDefinition); - return { - mock: mockApiKeyRepository, - helpers: { - // // Helper methods for test setup - setupCreateApiKey: ( - result: ReturnType - ) => { - mockDefinition.createApiKey.mockReturnValue(result); - }, - setupGetApiKeyById: ( - result: ReturnType - ) => { - mockDefinition.getApiKeyById.mockReturnValue(result); - }, - setupGetApiKeys: ( - result: ReturnType - ) => { - mockDefinition.getApiKeys.mockReturnValue(result); - }, - setupUpdateApiKey: ( - result: ReturnType - ) => { - mockDefinition.updateApiKey.mockReturnValue(result); - }, - setupDeleteApiKey: ( - result: ReturnType - ) => { - mockDefinition.deleteApiKey.mockReturnValue(result); - }, - // Helper to reset all mocks - reset: () => { - mockDefinition.createApiKey.mockReset(); - mockDefinition.getApiKeyById.mockReset(); - mockDefinition.getApiKeys.mockReset(); - mockDefinition.updateApiKey.mockReset(); - mockDefinition.deleteApiKey.mockReset(); - }, - }, - }; + const mockApiKeyRepository = new ApiKeyRepository(mockDefinition); + return { + mock: mockApiKeyRepository, + helpers: { + // // Helper methods for test setup + setupCreateApiKey: ( + result: ReturnType + ) => { + mockDefinition.createApiKey.mockReturnValue(result); + }, + setupGetApiKeyById: ( + result: ReturnType + ) => { + mockDefinition.getApiKeyById.mockReturnValue(result); + }, + setupGetApiKeys: ( + result: ReturnType + ) => { + mockDefinition.getApiKeys.mockReturnValue(result); + }, + setupUpdateApiKey: ( + result: ReturnType + ) => { + mockDefinition.updateApiKey.mockReturnValue(result); + }, + setupDeleteApiKey: ( + result: ReturnType + ) => { + mockDefinition.deleteApiKey.mockReturnValue(result); + }, + // Helper to reset all mocks + reset: () => { + mockDefinition.createApiKey.mockReset(); + mockDefinition.getApiKeyById.mockReset(); + mockDefinition.getApiKeys.mockReset(); + mockDefinition.updateApiKey.mockReset(); + mockDefinition.deleteApiKey.mockReset(); + } + } + }; }; export type MockApiKeyRepository = ReturnType< - typeof createMockApiKeyRepository + typeof createMockApiKeyRepository >; diff --git a/apps/web/lib/testing/__mocks__/utils.mock.ts b/apps/web/lib/testing/__mocks__/utils.mock.ts index c2bea3baf..e2b2f71a8 100644 --- a/apps/web/lib/testing/__mocks__/utils.mock.ts +++ b/apps/web/lib/testing/__mocks__/utils.mock.ts @@ -25,4 +25,4 @@ // } else { // return Effect.fail(new Error("Permission denied")); // } -// }; \ No newline at end of file +// }; diff --git a/apps/web/lib/testing/env.ts b/apps/web/lib/testing/env.ts index 85f367e11..0960dd461 100644 --- a/apps/web/lib/testing/env.ts +++ b/apps/web/lib/testing/env.ts @@ -1,16 +1,16 @@ -import { z } from "zod"; +import { z } from 'zod'; export const integrationTestEnv = z.object({ - E2E_BASE_URL: z.string().url().min(1), - DATABASE_HOST: z.string().min(1), - DATABASE_PORT: z.string().optional(), - DATABASE_USERNAME: z.string().min(1), - DATABASE_PASSWORD: z.string().min(1), - DATABASE_NAME: z.string().optional(), - CI: z.coerce - .string() - .default("false") - .transform((v) => v === "true"), + E2E_BASE_URL: z.string().url().min(1), + DATABASE_HOST: z.string().min(1), + DATABASE_PORT: z.string().optional(), + DATABASE_USERNAME: z.string().min(1), + DATABASE_PASSWORD: z.string().min(1), + DATABASE_NAME: z.string().optional(), + CI: z.coerce + .string() + .default('false') + .transform((v) => v === 'true') }); export const env = integrationTestEnv.parse(process.env); diff --git a/apps/web/lib/testing/harness.ts b/apps/web/lib/testing/harness.ts index 651fba566..d3340eb6d 100644 --- a/apps/web/lib/testing/harness.ts +++ b/apps/web/lib/testing/harness.ts @@ -1,300 +1,305 @@ // Credits: Inspired by https://github.com/unkeyed/unkey -import { Client } from "@planetscale/database"; -import { Database, eq, like } from "@voidhash/db"; -import type { TaskContext } from "vitest"; -import { drizzle as drizzleMysql } from "drizzle-orm/mysql2"; -import mysql from "mysql2/promise"; -import { drizzle as drizzlePlanetscale } from "drizzle-orm/planetscale-serverless"; -import * as schema from "@voidhash/db/schema"; +import { Client } from '@planetscale/database'; import type { - User, - Organization, - Project, - ApiKey, - PaymentProviderConfiguration, - Transaction, -} from "@voidhash/db"; -import { generateId } from "../id/generate"; -import { env, integrationTestEnv } from "./env"; -import { z } from "zod"; -import { hashKey } from "../core/api-keys/utils"; + ApiKey, + Organization, + PaymentProviderConfiguration, + Project, + Transaction, + User +} from '@voidhash/db'; +import { type Database, eq, like } from '@voidhash/db'; +import * as schema from '@voidhash/db/schema'; +import { Environment } from '@voidhash/lib/index'; +import { drizzle as drizzleMysql } from 'drizzle-orm/mysql2'; +import { drizzle as drizzlePlanetscale } from 'drizzle-orm/planetscale-serverless'; +import mysql from 'mysql2/promise'; +import type { TaskContext } from 'vitest'; +import type { z } from 'zod'; +import { hashKey } from '../core/api-keys/utils'; +import { generateId } from '../id/generate'; import { - stripe, - stripePaymentProviderId, -} from "../payment-providers/stripe/stripe"; + devCheckout, + devCheckoutPaymentProviderId +} from '../payment-providers/dev-checkout/dev-checkout'; import { - devCheckout, - devCheckoutPaymentProviderId, -} from "../payment-providers/dev-checkout/dev-checkout"; -import { Environment } from "@voidhash/lib/index"; -import { createMockUserAuthSession } from "./__mocks__/auth.mock"; + stripe, + stripePaymentProviderId +} from '../payment-providers/stripe/stripe'; +import { createMockUserAuthSession } from './__mocks__/auth.mock'; +import { env, type integrationTestEnv } from './env'; export type Resources = { - user: User; - organization: Organization; - project: Project; - secretKey: ApiKey & { unhashedKey: string }; - publishableKey: ApiKey & { unhashedKey: string }; - devCheckoutPaymentProviderConfiguration: PaymentProviderConfiguration; - paymentProviderConfiguration: PaymentProviderConfiguration; + user: User; + organization: Organization; + project: Project; + secretKey: ApiKey & { unhashedKey: string }; + publishableKey: ApiKey & { unhashedKey: string }; + devCheckoutPaymentProviderConfiguration: PaymentProviderConfiguration; + paymentProviderConfiguration: PaymentProviderConfiguration; }; export abstract class Harness { - public db: { primary: Database; readonly: Database }; - public resources: Resources; - private env: z.infer; + db: { primary: Database; readonly: Database }; + resources: Resources; + private env: z.infer; - constructor(t: TaskContext) { - this.env = env; - t.onTestFinished(async () => { - await this.teardown(); - }); - } + constructor(t: TaskContext) { + this.env = env; + t.onTestFinished(async () => { + await this.teardown(); + }); + } - protected async initHarness(): Promise { - const { - DATABASE_HOST, - DATABASE_PASSWORD, - DATABASE_USERNAME, - DATABASE_NAME, - } = this.env; + protected async initHarness(): Promise { + const { + DATABASE_HOST, + DATABASE_PASSWORD, + DATABASE_USERNAME, + DATABASE_NAME + } = this.env; - let db: Database; - if (DATABASE_HOST.includes("psdb.cloud")) { - const client = new Client({ - host: DATABASE_HOST, - username: DATABASE_USERNAME, - password: DATABASE_PASSWORD, - }); + let db: Database; + if (DATABASE_HOST.includes('psdb.cloud')) { + const client = new Client({ + host: DATABASE_HOST, + username: DATABASE_USERNAME, + password: DATABASE_PASSWORD + }); - db = drizzlePlanetscale(client, { schema }); - } else { - const connection = await mysql.createConnection({ - host: DATABASE_HOST, - user: DATABASE_USERNAME, - database: DATABASE_NAME, - password: DATABASE_PASSWORD, - }); + db = drizzlePlanetscale(client, { schema }); + } else { + const connection = await mysql.createConnection({ + host: DATABASE_HOST, + user: DATABASE_USERNAME, + database: DATABASE_NAME, + password: DATABASE_PASSWORD + }); - db = drizzleMysql({ - client: connection, - schema, - mode: "default", - }); - } + db = drizzleMysql({ + client: connection, + schema, + mode: 'default' + }); + } - this.db = { primary: db, readonly: db }; + this.db = { primary: db, readonly: db }; - this.resources = await this.createResources(); - } + this.resources = await this.createResources(); + } - private async teardown(): Promise { - const deleteResources = async () => { - await this.db.primary.transaction(async (tx: Transaction) => { - // Delete all previous test ids - await tx.delete(schema.apiKeys).where(like(schema.apiKeys.id, "test%")); - await tx - .delete(schema.projects) - .where(like(schema.projects.id, "test%")); - await tx - .delete(schema.organization) - .where(like(schema.organization.id, "test%")); - await tx.delete(schema.user).where(like(schema.user.id, "test%")); - await tx - .delete(schema.customers) - .where(like(schema.customers.id, "test%")); - await tx - .delete(schema.purchases) - .where(like(schema.purchases.id, "test%")); - await tx - .delete(schema.products) - .where(like(schema.products.id, "test%")); - await tx - .delete(schema.paymentProviderConfigurationProducts) - .where(like(schema.paymentProviderConfigurationProducts.id, "test%")); + private async teardown(): Promise { + const deleteResources = async () => { + await this.db.primary.transaction(async (tx: Transaction) => { + // Delete all previous test ids + await tx.delete(schema.apiKeys).where(like(schema.apiKeys.id, 'test%')); + await tx + .delete(schema.projects) + .where(like(schema.projects.id, 'test%')); + await tx + .delete(schema.organization) + .where(like(schema.organization.id, 'test%')); + await tx.delete(schema.user).where(like(schema.user.id, 'test%')); + await tx + .delete(schema.customers) + .where(like(schema.customers.id, 'test%')); + await tx + .delete(schema.purchases) + .where(like(schema.purchases.id, 'test%')); + await tx + .delete(schema.products) + .where(like(schema.products.id, 'test%')); + await tx + .delete(schema.paymentProviderConfigurationProducts) + .where(like(schema.paymentProviderConfigurationProducts.id, 'test%')); - await tx - .delete(schema.paymentProviderConfigurations) - .where(like(schema.paymentProviderConfigurations.id, "test%")); - await tx - .delete(schema.productPerks) - .where(like(schema.productPerks.id, "test%")); - await tx.delete(schema.perks).where(like(schema.perks.id, "test%")); + await tx + .delete(schema.paymentProviderConfigurations) + .where(like(schema.paymentProviderConfigurations.id, 'test%')); + await tx + .delete(schema.productPerks) + .where(like(schema.productPerks.id, 'test%')); + await tx.delete(schema.perks).where(like(schema.perks.id, 'test%')); - await tx - .delete(schema.organization) - .where(eq(schema.organization.id, this.resources.organization.id)); + await tx + .delete(schema.organization) + .where(eq(schema.organization.id, this.resources.organization.id)); - await tx - .delete(schema.user) - .where(eq(schema.user.id, this.resources.user.id)); - }); - }; - for (let i = 1; i <= 5; i++) { - try { - await deleteResources(); - return; - } catch (err) { - if (i === 5) { - throw err; - } - await new Promise((r) => setTimeout(r, i * 500)); - } - } - } + await tx + .delete(schema.user) + .where(eq(schema.user.id, this.resources.user.id)); + }); + }; + for (let i = 1; i <= 5; i++) { + try { + // biome-ignore lint/nursery/noAwaitInLoop: it is required here + await deleteResources(); + return; + } catch (err) { + if (i === 5) { + throw err; + } + await new Promise((r) => setTimeout(r, i * 500)); + } + } + } - public async createResources(): Promise { - const user: User = { - id: generateId("test"), - name: "Test User", - email: `${generateId("test")}@test.com`, - createdAt: new Date(), - updatedAt: new Date(), - emailVerified: true, - image: null, - }; + async createResources(): Promise { + const user: User = { + id: generateId('test'), + name: 'Test User', + email: `${generateId('test')}@test.com`, + createdAt: new Date(), + updatedAt: new Date(), + emailVerified: true, + image: null + }; - const organization: Organization = { - id: generateId("test"), - name: "Test Organization", - slug: `${generateId("test")}-organization`, - logo: null, - createdAt: new Date(), - metadata: null, - }; + const organization: Organization = { + id: generateId('test'), + name: 'Test Organization', + slug: `${generateId('test')}-organization`, + logo: null, + createdAt: new Date(), + metadata: null + }; - const project: Project = { - id: generateId("test"), - name: "Test Project", - slug: `${generateId("test")}-project`, - createdByUserId: user.id, - createdAt: new Date(), - updatedAt: new Date(), - organizationId: organization.id, - }; + const project: Project = { + id: generateId('test'), + name: 'Test Project', + slug: `${generateId('test')}-project`, + createdByUserId: user.id, + createdAt: new Date(), + updatedAt: new Date(), + organizationId: organization.id + }; - const devCheckoutConfigurationId = generateId("test"); - const devCheckoutPaymentProviderConfiguration: PaymentProviderConfiguration = - { - id: devCheckoutConfigurationId, - projectId: project.id, - providerId: devCheckoutPaymentProviderId, - name: "DevCheckout", - enabled: true, - paymentProviderKey: devCheckout.createGlobalKey({ - paymentProviderConfigurationId: devCheckoutConfigurationId, - }), - configuration: {}, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - }; + const devCheckoutConfigurationId = generateId('test'); + const devCheckoutPaymentProviderConfiguration: PaymentProviderConfiguration = + { + id: devCheckoutConfigurationId, + projectId: project.id, + providerId: devCheckoutPaymentProviderId, + name: 'DevCheckout', + enabled: true, + paymentProviderKey: devCheckout.createGlobalKey({ + paymentProviderConfigurationId: devCheckoutConfigurationId + }), + configuration: {}, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null + }; - const paymentProviderConfiguration: PaymentProviderConfiguration = { - id: generateId("test"), - projectId: project.id, - providerId: stripePaymentProviderId, - name: "Stripe", - enabled: true, - paymentProviderKey: stripe.createGlobalKey({ - secretKey: "sk_test_123", - webhookSecret: "whsec_123", - }), - configuration: { - secretKey: "sk_test_123", - webhookSecret: "whsec_123", - } satisfies z.infer< - ReturnType - >, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - }; + const paymentProviderConfiguration: PaymentProviderConfiguration = { + id: generateId('test'), + projectId: project.id, + providerId: stripePaymentProviderId, + name: 'Stripe', + enabled: true, + paymentProviderKey: stripe.createGlobalKey({ + secretKey: 'sk_test_123', + webhookSecret: 'whsec_123' + }), + configuration: { + secretKey: 'sk_test_123', + webhookSecret: 'whsec_123' + } satisfies z.infer< + ReturnType + >, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null + }; - const unhashedKey = "test-secret-key"; - const hashedKey = await hashKey(unhashedKey); + const unhashedKey = 'test-secret-key'; + const hashedKey = await hashKey(unhashedKey); - const secretKey: ApiKey & { unhashedKey: string } = { - id: generateId("test"), - name: "Test Secret Key", - key: hashedKey, - unhashedKey, - createdAt: new Date(), - updatedAt: new Date(), - prefix: "test_", - end: "1234", - isPublic: false, - environment: Environment.Production, - projectId: project.id, - }; + const secretKey: ApiKey & { unhashedKey: string } = { + id: generateId('test'), + name: 'Test Secret Key', + key: hashedKey, + unhashedKey, + createdAt: new Date(), + updatedAt: new Date(), + prefix: 'test_', + end: '1234', + isPublic: false, + environment: Environment.Production, + projectId: project.id + }; - const testPublishableKey = "test-publishable-key"; - const publishableKey: ApiKey & { unhashedKey: string } = { - id: generateId("test"), - name: "Test Publishable Key", - key: testPublishableKey, - unhashedKey: testPublishableKey, - createdAt: new Date(), - updatedAt: new Date(), - prefix: "test_", - end: "1234", - isPublic: true, - environment: Environment.Production, - projectId: project.id, - }; + const testPublishableKey = 'test-publishable-key'; + const publishableKey: ApiKey & { unhashedKey: string } = { + id: generateId('test'), + name: 'Test Publishable Key', + key: testPublishableKey, + unhashedKey: testPublishableKey, + createdAt: new Date(), + updatedAt: new Date(), + prefix: 'test_', + end: '1234', + isPublic: true, + environment: Environment.Production, + projectId: project.id + }; - return { - user, - organization, - project, - secretKey, - publishableKey, - devCheckoutPaymentProviderConfiguration, - paymentProviderConfiguration, - }; - } + return { + user, + organization, + project, + secretKey, + publishableKey, + devCheckoutPaymentProviderConfiguration, + paymentProviderConfiguration + }; + } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public createAuthSession(options: {type: "user" | "apiKey"}) { - // if (options.type === "user") { - // TODO: Improve this - return createMockUserAuthSession({ - user: this.resources.user, - organizations: [{ - id: this.resources.organization.id, - permissions: ["organization:all"], - slug: this.resources.organization.slug ?? "org-slug", - }], - projects: [{ - id: this.resources.project.id, - permissions: ["project:all"], - organizationId: this.resources.organization.id, - slug: this.resources.project.slug ?? "project-slug", - }] - }) - // } - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + createAuthSession(_options: { type: 'user' | 'apiKey' }) { + // if (options.type === "user") { + // TODO: Improve this + return createMockUserAuthSession({ + user: this.resources.user, + organizations: [ + { + id: this.resources.organization.id, + permissions: ['organization:all'], + slug: this.resources.organization.slug ?? 'org-slug' + } + ], + projects: [ + { + id: this.resources.project.id, + permissions: ['project:all'], + organizationId: this.resources.organization.id, + slug: this.resources.project.slug ?? 'project-slug' + } + ] + }); + // } + } - protected async seed(): Promise { - await this.db.primary.insert(schema.user).values(this.resources.user); - await this.db.primary - .insert(schema.organization) - .values(this.resources.organization); - await this.db.primary - .insert(schema.projects) - .values(this.resources.project); - await this.db.primary - .insert(schema.apiKeys) - .values(this.resources.secretKey); - await this.db.primary - .insert(schema.apiKeys) - .values(this.resources.publishableKey); - await this.db.primary - .insert(schema.paymentProviderConfigurations) - .values(this.resources.paymentProviderConfiguration); - await this.db.primary - .insert(schema.paymentProviderConfigurations) - .values(this.resources.devCheckoutPaymentProviderConfiguration); - } + protected async seed(): Promise { + await this.db.primary.insert(schema.user).values(this.resources.user); + await this.db.primary + .insert(schema.organization) + .values(this.resources.organization); + await this.db.primary + .insert(schema.projects) + .values(this.resources.project); + await this.db.primary + .insert(schema.apiKeys) + .values(this.resources.secretKey); + await this.db.primary + .insert(schema.apiKeys) + .values(this.resources.publishableKey); + await this.db.primary + .insert(schema.paymentProviderConfigurations) + .values(this.resources.paymentProviderConfiguration); + await this.db.primary + .insert(schema.paymentProviderConfigurations) + .values(this.resources.devCheckoutPaymentProviderConfiguration); + } } diff --git a/apps/web/lib/testing/integration-harness.ts b/apps/web/lib/testing/integration-harness.ts index ad7c1b9e3..c153c8f13 100644 --- a/apps/web/lib/testing/integration-harness.ts +++ b/apps/web/lib/testing/integration-harness.ts @@ -1,51 +1,51 @@ // Credits: Inspired by https://github.com/unkeyed/unkey -import type { TaskContext } from "vitest"; -import { Harness } from "./harness"; -import { type StepRequest, type StepResponse, step } from "./request"; -import { env } from "./env"; +import type { TaskContext } from 'vitest'; +import { env } from './env'; +import { Harness } from './harness'; +import { type StepRequest, type StepResponse, step } from './request'; export class IntegrationHarness extends Harness { - public readonly baseUrl: string; + readonly baseUrl: string; - private constructor(t: TaskContext) { - super(t); - this.baseUrl = env.E2E_BASE_URL; - } + private constructor(t: TaskContext) { + super(t); + this.baseUrl = env.E2E_BASE_URL; + } - static async init(t: TaskContext): Promise { - const h = new IntegrationHarness(t); - await h.initHarness(); - await h.seed(); - return h; - } + static async init(t: TaskContext): Promise { + const h = new IntegrationHarness(t); + await h.initHarness(); + await h.seed(); + return h; + } - async do( - req: StepRequest - ): Promise> { - const reqWithUrl: StepRequest = { - ...req, - url: new URL(this.baseUrl + req.url).toString(), - }; - return step(reqWithUrl); - } - async get( - req: Omit, "method"> - ): Promise> { - return this.do({ method: "GET", ...req }); - } - async post( - req: Omit, "method"> - ): Promise> { - return this.do({ method: "POST", ...req }); - } - async put( - req: Omit, "method"> - ): Promise> { - return this.do({ method: "PUT", ...req }); - } - async delete( - req: Omit, "method"> - ): Promise> { - return this.do({ method: "DELETE", ...req }); - } + do( + req: StepRequest + ): Promise> { + const reqWithUrl: StepRequest = { + ...req, + url: new URL(this.baseUrl + req.url).toString() + }; + return step(reqWithUrl); + } + get( + req: Omit, 'method'> + ): Promise> { + return this.do({ method: 'GET', ...req }); + } + post( + req: Omit, 'method'> + ): Promise> { + return this.do({ method: 'POST', ...req }); + } + put( + req: Omit, 'method'> + ): Promise> { + return this.do({ method: 'PUT', ...req }); + } + delete( + req: Omit, 'method'> + ): Promise> { + return this.do({ method: 'DELETE', ...req }); + } } diff --git a/apps/web/lib/testing/request.ts b/apps/web/lib/testing/request.ts index 1b817963c..4e94ccab3 100644 --- a/apps/web/lib/testing/request.ts +++ b/apps/web/lib/testing/request.ts @@ -1,93 +1,98 @@ -import { ExecutionContext } from "hono"; -import { App } from "../api/hono/app"; +import type { ExecutionContext } from 'hono'; +import type { App } from '../api/hono/app'; // Credits: Inspired by https://github.com/unkeyed/unkey export type StepRequest = { - url: string; - method: "POST" | "GET" | "PUT" | "DELETE"; - headers?: Record; - searchparams?: Record; - body?: TRequestBody; + url: string; + method: 'POST' | 'GET' | 'PUT' | 'DELETE'; + headers?: Record; + searchparams?: Record; + body?: TRequestBody; }; export type StepResponse = { - status: number; - headers: Record; - body: TBody; + status: number; + headers: Record; + body: TBody; }; export async function step( - req: StepRequest + req: StepRequest ): Promise> { - const url = new URL(req.url); - for (const [k, vv] of Object.entries(req.searchparams ?? {})) { - if (Array.isArray(vv)) { - for (const v of vv) { - url.searchParams.append(k, v); - } - } else { - url.searchParams.append(k, vv); - } - } + const url = new URL(req.url); + for (const [k, vv] of Object.entries(req.searchparams ?? {})) { + if (Array.isArray(vv)) { + for (const v of vv) { + url.searchParams.append(k, v); + } + } else { + url.searchParams.append(k, vv); + } + } - const res = await fetch(url, { - method: req.method, - headers: req.headers, - body: JSON.stringify(req.body), - }); + const res = await fetch(url, { + method: req.method, + headers: req.headers, + body: JSON.stringify(req.body) + }); - const body = await res.text(); - try { - return { - status: res.status, - headers: headersToRecord(res.headers), - body: JSON.parse(body), - }; - } catch { - console.error(`${url.toString()} didn't return json, received: ${body}`); - return {} as StepResponse; - } + const body = await res.text(); + try { + return { + status: res.status, + headers: headersToRecord(res.headers), + body: JSON.parse(body) + }; + } catch { + // biome-ignore lint/suspicious/noConsole: Test request console logging + console.error(`${url.toString()} didn't return json, received: ${body}`); + return {} as StepResponse; + } } export async function fetchRoute< - TRequestBody = unknown, - TResponseBody = unknown, + TRequestBody = unknown, + TResponseBody = unknown >( - app: App, - req: StepRequest + app: App, + req: StepRequest ): Promise> { - const eCtx: ExecutionContext = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - waitUntil: (promise: Promise) => { - promise.catch(() => {}); - }, - passThroughOnException: () => {}, - }; + const eCtx: ExecutionContext = { + // biome-ignore lint/suspicious/noExplicitAny: should be ok + waitUntil: (promise: Promise) => { + // biome-ignore lint/suspicious/noEmptyBlockStatements: mock + promise.catch(() => {}); + }, + // biome-ignore lint/suspicious/noEmptyBlockStatements: mock + passThroughOnException: () => {}, + props: undefined + }; - const res = await app.request( - req.url, - { - method: req.method, - headers: req.headers, - body: JSON.stringify(req.body), - }, - {}, // Env - eCtx - ); + const res = await app.request( + req.url, + { + method: req.method, + headers: req.headers, + body: JSON.stringify(req.body) + }, + {}, // Env + eCtx + ); - return { - status: res.status, - headers: headersToRecord(res.headers), - body: (await res.json().catch((err) => { - console.error(`${req.url} didn't return json`, err); - return {}; - })) as TResponseBody, - }; + return { + status: res.status, + headers: headersToRecord(res.headers), + body: (await res.json().catch((err) => { + // biome-ignore lint/suspicious/noConsole: Test request console logging + console.error(`${req.url} didn't return json`, err); + return {}; + })) as TResponseBody + }; } export function headersToRecord(headers: Headers): Record { - const rec: Record = {}; - headers.forEach((v, k) => { - rec[k] = v; - }); - return rec; + const rec: Record = {}; + headers.forEach((v, k) => { + rec[k] = v; + }); + return rec; } diff --git a/apps/web/lib/tinybird/client.ts b/apps/web/lib/tinybird/client.ts index 0d6dfeeb8..6239d203c 100644 --- a/apps/web/lib/tinybird/client.ts +++ b/apps/web/lib/tinybird/client.ts @@ -1,6 +1,6 @@ -import { Tinybird } from "@chronark/zod-bird"; +import { Tinybird } from '@chronark/zod-bird'; export const tb = new Tinybird({ - token: process.env.TINYBIRD_API_KEY as string, - baseUrl: process.env.TINYBIRD_API_URL as string, + token: process.env.TINYBIRD_API_KEY as string, + baseUrl: process.env.TINYBIRD_API_URL as string }); diff --git a/apps/web/lib/trpc/auth/router.ts b/apps/web/lib/trpc/auth/router.ts index 27bcb8785..8dc032085 100644 --- a/apps/web/lib/trpc/auth/router.ts +++ b/apps/web/lib/trpc/auth/router.ts @@ -1,23 +1,23 @@ -import { auth } from "@voidhash/auth"; -import { createTRPCRouter, publicProcedure } from "@/lib/trpc/trpc"; +import { auth } from '@voidhash/auth'; +import { createTRPCRouter, publicProcedure } from '@/lib/trpc/trpc'; export const authRouter = createTRPCRouter({ - me: publicProcedure.query(async ({ ctx }) => { - const session = await auth.api.getSession({ - headers: ctx.headers, - }); + me: publicProcedure.query(async ({ ctx }) => { + const session = await auth.api.getSession({ + headers: ctx.headers + }); - if (!session?.user) { - return null; - } + if (!session?.user) { + return null; + } - const organizations = await auth.api.listOrganizations({ - headers: ctx.headers, - }); + const organizations = await auth.api.listOrganizations({ + headers: ctx.headers + }); - return { - ...session.user, - organizations, - }; - }), + return { + ...session.user, + organizations + }; + }) }); diff --git a/apps/web/lib/trpc/index.ts b/apps/web/lib/trpc/index.ts index 1cbe6fdd7..0c4b9fb14 100644 --- a/apps/web/lib/trpc/index.ts +++ b/apps/web/lib/trpc/index.ts @@ -1,8 +1,8 @@ -import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; +import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; -import type { AppRouter } from "./root"; -import { appRouter } from "./root"; -import { createCallerFactory, createTRPCContext } from "./trpc"; +import type { AppRouter } from './root'; +import { appRouter } from './root'; +import { createCallerFactory, createTRPCContext } from './trpc'; /** * Create a server-side caller for the tRPC API @@ -18,7 +18,7 @@ const createCaller = createCallerFactory(appRouter); * @example * type PostByIdInput = RouterInputs['post']['byId'] * ^? { id: number } - **/ + */ type RouterInputs = inferRouterInputs; /** @@ -26,7 +26,7 @@ type RouterInputs = inferRouterInputs; * @example * type AllPostsOutput = RouterOutputs['post']['all'] * ^? Post[] - **/ + */ type RouterOutputs = inferRouterOutputs; export { createTRPCContext, appRouter, createCaller }; diff --git a/apps/web/lib/trpc/projects/router.ts b/apps/web/lib/trpc/projects/router.ts index 793c5a676..eb1bff893 100644 --- a/apps/web/lib/trpc/projects/router.ts +++ b/apps/web/lib/trpc/projects/router.ts @@ -1,18 +1,18 @@ -import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { getTeamsProjectsBySlugSchema } from "./schema"; -import { organization, projects } from "@voidhash/db"; -import { eq } from "drizzle-orm"; +import { organization, projects } from '@voidhash/db'; +import { eq } from 'drizzle-orm'; +import { createTRPCRouter, protectedProcedure } from '../trpc'; +import { getTeamsProjectsBySlugSchema } from './schema'; export const projectsRouter = createTRPCRouter({ - getTeamsProjectsBySlug: protectedProcedure - .input(getTeamsProjectsBySlugSchema) - .query(async ({ input, ctx }) => { - const teamProjects = await ctx.db - .select() - .from(projects) - .innerJoin(organization, eq(projects.organizationId, organization.id)) - .where(eq(organization.slug, input.organizationSlug)); + getTeamsProjectsBySlug: protectedProcedure + .input(getTeamsProjectsBySlugSchema) + .query(async ({ input, ctx }) => { + const teamProjects = await ctx.db + .select() + .from(projects) + .innerJoin(organization, eq(projects.organizationId, organization.id)) + .where(eq(organization.slug, input.organizationSlug)); - return teamProjects.map((project) => project.project); - }), + return teamProjects.map((project) => project.project); + }) }); diff --git a/apps/web/lib/trpc/projects/schema.ts b/apps/web/lib/trpc/projects/schema.ts index 0d75a1dd4..cee72247a 100644 --- a/apps/web/lib/trpc/projects/schema.ts +++ b/apps/web/lib/trpc/projects/schema.ts @@ -1,19 +1,19 @@ -import { z } from "zod"; +import { z } from 'zod'; export const createProjectSchema = z.object({ - name: z.string().min(1).max(32), - organizationId: z.string(), + name: z.string().min(1).max(32), + organizationId: z.string() }); export const getTeamsProjectsBySlugSchema = z.object({ - organizationSlug: z.string(), + organizationSlug: z.string() }); export const updateProjectSchema = z.object({ - projectId: z.string(), - name: z.string().min(1).max(32), + projectId: z.string(), + name: z.string().min(1).max(32) }); export const deleteProjectSchema = z.object({ - projectId: z.string(), + projectId: z.string() }); diff --git a/apps/web/lib/trpc/root.ts b/apps/web/lib/trpc/root.ts index 8f71125a2..400a69ad7 100644 --- a/apps/web/lib/trpc/root.ts +++ b/apps/web/lib/trpc/root.ts @@ -1,10 +1,10 @@ -import { createTRPCRouter } from "./trpc"; -import { authRouter } from "./auth/router"; -import { projectsRouter } from "./projects/router"; +import { authRouter } from './auth/router'; +import { projectsRouter } from './projects/router'; +import { createTRPCRouter } from './trpc'; export const appRouter = createTRPCRouter({ - auth: authRouter, - projects: projectsRouter, + auth: authRouter, + projects: projectsRouter }); // export type definition of API diff --git a/apps/web/lib/trpc/trpc.ts b/apps/web/lib/trpc/trpc.ts index f73407ba4..a09e81259 100644 --- a/apps/web/lib/trpc/trpc.ts +++ b/apps/web/lib/trpc/trpc.ts @@ -6,12 +6,12 @@ * 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 { auth } from "@voidhash/auth"; -import { db } from "@voidhash/db"; -import { VoidhashHTTPError } from "@voidhash/lib"; -import superjson from "superjson"; -import { ZodError } from "zod"; +import { initTRPC, TRPCError } from '@trpc/server'; +import { auth } from '@voidhash/auth'; +import { db } from '@voidhash/db'; +import { VoidhashHTTPError } from '@voidhash/lib'; +import superjson from 'superjson'; +import { ZodError } from 'zod'; /** * 1. CONTEXT * @@ -25,31 +25,31 @@ import { ZodError } from "zod"; * @see https://trpc.io/docs/server/context */ export const createTRPCContext = async (opts: { - headers: Headers; - session: { - user: { - id: string; - email: string; - name: string; - emailVerified: boolean; - image?: string | null; - createdAt: Date; - updatedAt: Date; - }; - } | null; + headers: Headers; + session: { + user: { + id: string; + email: string; + name: string; + emailVerified: boolean; + image?: string | null; + createdAt: Date; + updatedAt: Date; + }; + } | null; }) => { - const session = await auth.api.getSession({ - headers: opts.headers, - }); + const session = await auth.api.getSession({ + headers: opts.headers + }); - // const source = opts.headers.get("x-trpc-source") ?? "unknown"; - // console.log(">>> tRPC Request from", source, "by", session?.user); + // const source = opts.headers.get("x-trpc-source") ?? "unknown"; + // console.log(">>> tRPC Request from", source, "by", session?.user); - return { - headers: opts.headers, - session, - db, - }; + return { + headers: opts.headers, + session, + db + }; }; /** @@ -59,16 +59,16 @@ export const createTRPCContext = async (opts: { * transformer */ const t = initTRPC.context().create({ - transformer: superjson, - errorFormatter: ({ shape, error }) => ({ - ...shape, - data: { - ...shape.data, - zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, - voidhashError: - error.cause instanceof VoidhashHTTPError ? error.cause : null, - }, - }), + transformer: superjson, + errorFormatter: ({ shape, error }) => ({ + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + voidhashError: + error.cause instanceof VoidhashHTTPError ? error.cause : null + } + }) }); /** @@ -97,20 +97,21 @@ export const createTRPCRouter = t.router; * network latency that would occur in production but not in local development. */ const timingMiddleware = t.middleware(async ({ next, path }) => { - const start = Date.now(); + const start = Date.now(); - if (t._config.isDev) { - // artificial delay in dev 100-500ms - const waitMs = Math.floor(Math.random() * 400) + 100; - await new Promise((resolve) => setTimeout(resolve, waitMs)); - } + if (t._config.isDev) { + // artificial delay in dev 100-500ms + const waitMs = Math.floor(Math.random() * 400) + 100; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } - const result = await next(); + const result = await next(); - const end = Date.now(); - console.log(`[TRPC] ${path} took ${end - start}ms to execute`); + const end = Date.now(); + // biome-ignore lint/suspicious/noConsole: logging + console.log(`[TRPC] ${path} took ${end - start}ms to execute`); - return result; + return result; }); /** @@ -131,15 +132,15 @@ export const publicProcedure = t.procedure.use(timingMiddleware); * @see https://trpc.io/docs/procedures */ export const protectedProcedure = t.procedure - .use(timingMiddleware) - .use(({ 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 }, - }, - }); - }); + .use(timingMiddleware) + .use(({ 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 } + } + }); + }); diff --git a/apps/web/lib/voidhash.ts b/apps/web/lib/voidhash.ts index c9e915c5a..e2609708b 100644 --- a/apps/web/lib/voidhash.ts +++ b/apps/web/lib/voidhash.ts @@ -1,4 +1,4 @@ -import { createVoidhash } from "voidhash"; -import { env } from "./env"; +import { createVoidhash } from 'voidhash'; +import { env } from './env'; export const voidhash = createVoidhash(env.VOIDHASH_SECRET_KEY); diff --git a/apps/web/lib/zod-error.ts b/apps/web/lib/zod-error.ts index 578d2852b..46a32ea13 100644 --- a/apps/web/lib/zod-error.ts +++ b/apps/web/lib/zod-error.ts @@ -1,15 +1,15 @@ // Credited to https://github.com/unkeyed/unkey -import type { z } from "zod"; +import type { z } from 'zod'; export function parseZodErrorMessage(err: z.ZodError): string { - try { - const arr = JSON.parse(err.message) as Array<{ - message: string; - path: Array; - }>; - const { path, message } = arr[0] ?? { path: [], message: err.message }; - return `${path.join(".")}: ${message}`; - } catch { - return err.message; - } + try { + const arr = JSON.parse(err.message) as Array<{ + message: string; + path: string[]; + }>; + const { path, message } = arr[0] ?? { path: [], message: err.message }; + return `${path.join('.')}: ${message}`; + } catch { + return err.message; + } } diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index d85df20ba..7886ffffd 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,45 +1,45 @@ -import ApiMiddleware from "./lib/middleware/api"; -import AppMiddleware from "./lib/middleware/app"; -import CheckoutMiddleware from "./lib/middleware/checkout"; -import { parse } from "./lib/middleware/utils/parse"; import { - API_HOSTNAMES, - APP_HOSTNAMES, - CHECKOUT_HOSTNAMES, -} from "@voidhash/lib"; -import { NextRequest } from "next/server"; + API_HOSTNAMES, + APP_HOSTNAMES, + CHECKOUT_HOSTNAMES +} from '@voidhash/lib'; +import type { NextRequest } from 'next/server'; +import ApiMiddleware from './lib/middleware/api'; +import AppMiddleware from './lib/middleware/app'; +import CheckoutMiddleware from './lib/middleware/checkout'; +import { parse } from './lib/middleware/utils/parse'; export const config = { - matcher: [ - /* - * Match all paths except for: - * 1. /api/ routes - * 2. /_next/ (Next.js internals) - * 3. /_proxy/ (proxies for third-party services) - * 4. Metadata files: favicon.ico, sitemap.xml, robots.txt, manifest.webmanifest - */ - "/((?!api/|_next/|_proxy/|favicon.ico|sitemap.xml|robots.txt|manifest.webmanifest).*)", - ], + matcher: [ + /* + * Match all paths except for: + * 1. /api/ routes + * 2. /_next/ (Next.js internals) + * 3. /_proxy/ (proxies for third-party services) + * 4. Metadata files: favicon.ico, sitemap.xml, robots.txt, manifest.webmanifest + */ + '/((?!api/|_next/|_proxy/|favicon.ico|sitemap.xml|robots.txt|manifest.webmanifest|manifest.json).*)' + ] }; -export default async function middleware(req: NextRequest) { - const { domain, path } = parse(req); +export default function middleware(req: NextRequest) { + const { domain, path } = parse(req); - if ( - APP_HOSTNAMES.has(domain) && - !path.startsWith("/checkout.voidhash.com") && - path !== "/api" && - !path.startsWith("/docs") - ) { - return AppMiddleware(req); - } + if ( + APP_HOSTNAMES.has(domain) && + !path.startsWith('/checkout.voidhash.com') && + path !== '/api' && + !path.startsWith('/docs') + ) { + return AppMiddleware(req); + } - // for API - if (API_HOSTNAMES.has(domain)) { - return ApiMiddleware(req); - } + // for API + if (API_HOSTNAMES.has(domain)) { + return ApiMiddleware(req); + } - // for checkout - if (CHECKOUT_HOSTNAMES.has(domain)) { - return CheckoutMiddleware(req); - } + // for checkout + if (CHECKOUT_HOSTNAMES.has(domain)) { + return CheckoutMiddleware(req); + } } diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index d577104bf..0a0688b0e 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,41 +1,41 @@ -import { API_DOMAIN, DOCS_DOMAIN } from "@voidhash/lib/constants"; -import "./lib/env"; +import { API_DOMAIN, DOCS_DOMAIN } from '@voidhash/lib/constants'; +import './lib/env'; // Import env files to validate at build time. Use jiti so we can load .ts files in here. const nextConfig = { - transpilePackages: [ - "@voidhash/ui", - "@voidhash/auth", - "@voidhash/db", - "@voidhash/lib", - "@voidhash/emails", - ], - serverExternalPackages: ["pino", "@axiomhq/pino"], - async rewrites() { - return [ - { - source: "/api/:path*", - has: [ - { - type: "host", - value: "voidhash.com", - }, - ], - destination: `${API_DOMAIN}/:path*`, - }, - { - source: "/docs/:path*", - has: [ - { - type: "host", - value: "voidhash.com", - }, - ], - destination: `${DOCS_DOMAIN}/:path*`, - }, - ]; - }, + transpilePackages: [ + '@voidhash/ui', + '@voidhash/auth', + '@voidhash/db', + '@voidhash/lib', + '@voidhash/emails' + ], + serverExternalPackages: ['pino', '@axiomhq/pino'], + rewrites() { + return [ + { + source: '/api/:path*', + has: [ + { + type: 'host', + value: 'voidhash.com' + } + ], + destination: `${API_DOMAIN}/:path*` + }, + { + source: '/docs/:path*', + has: [ + { + type: 'host', + value: 'voidhash.com' + } + ], + destination: `${DOCS_DOMAIN}/:path*` + } + ]; + } }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 92ec79506..c66b46d3e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,26 +3,29 @@ "version": "0.0.1-alpha.1", "private": true, "scripts": { - "dev": "pnpm with-env next dev --turbopack", + "dev": "bun with-env next dev --turbopack", "typecheck": "tsc --noEmit", "build": "next build", "start": "next start", "lint": "next lint", "test": "vitest run -c vitest.unit.mts", "test:integration": "vitest run -c vitest.integration.mts --bail=1", - "trigger:dev": "pnpm with-env npx trigger.dev@latest dev", - "trigger:deploy": "pnpm with-env npx trigger.dev@latest deploy", - "trigger:deploy-staging": "pnpm with-env npx trigger.dev@latest deploy --env staging", + "trigger:dev": "bun with-env npx trigger.dev@latest dev", + "trigger:deploy": "bun with-env npx trigger.dev@latest deploy", + "trigger:deploy-staging": "bun with-env npx trigger.dev@latest deploy --env staging", "with-env": "dotenv -e .env --" }, "dependencies": { + "@apple/app-store-server-library": "^1.6.0", "@axiomhq/pino": "^1.3.1", "@chronark/zod-bird": "^0.3.10", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", + "@effect/platform": "^0.87.12", + "@effect/platform-node": "^0.89.5", "@hono/zod-openapi": "beta", - "@hono/zod-validator": "^0.7.0", + "@hono/zod-validator": "^0.7.2", "@hookform/resolvers": "^5.1.1", "@polar-sh/nextjs": "^0.4.0", "@react-three/drei": "^10.1.2", @@ -40,7 +43,6 @@ "@voidhash/db": "workspace:*", "@voidhash/lib": "workspace:*", "@voidhash/ui": "workspace:*", - "app-store-server-api": "^0.17.1", "better-auth": "^1.2.4", "cross-fetch": "^4.1.0", "drizzle-orm": "^0.44.2", @@ -65,30 +67,23 @@ "vite-tsconfig-paths": "^5.1.4", "voidhash": "0.0.1-alpha.4", "zod": "^4.0.2", - "zod-openapi": "^5.1.0" + "zod-openapi": "^5.3.1" }, "devDependencies": { "@effect/language-service": "^0.24.0", "@effect/vitest": "^0.23.12", - "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@trigger.dev/build": "^3.3.17", "@types/node": "^24.0.12", "@types/react": "^19", "@types/react-dom": "^19", - "@typescript-eslint/eslint-plugin": "^8.32.1", - "@typescript-eslint/parser": "^8.32.1", "@vitest/ui": "^3.2.4", "@voidhash/tsconfig": "workspace:*", "babel-plugin-react-compiler": "19.1.0-rc.2", "dotenv-cli": "^7.4.4", - "eslint": "^9.30.1", - "eslint-config-next": "15.3.5", - "eslint-plugin-neverthrow": "^1.1.4", - "eslint-plugin-neverthrow-must-use": "^0.1.2", "tailwindcss": "^4", "tw-animate-css": "^1.2.4", "typescript": "^5.8.3", "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs index 61e36849c..6ebbee33a 100644 --- a/apps/web/postcss.config.mjs +++ b/apps/web/postcss.config.mjs @@ -1,7 +1,7 @@ const config = { plugins: { - "@tailwindcss/postcss": {}, - }, + '@tailwindcss/postcss': {} + } }; export default config; diff --git a/apps/web/trigger.config.ts b/apps/web/trigger.config.ts index 9a526cfed..8d634f018 100644 --- a/apps/web/trigger.config.ts +++ b/apps/web/trigger.config.ts @@ -1,23 +1,23 @@ -import { defineConfig } from "@trigger.dev/sdk/v3"; -import { env } from "./lib/env"; +import { defineConfig } from '@trigger.dev/sdk/v3'; +import { env } from './lib/env'; export default defineConfig({ - project: env.TRIGGER_PROJECT_ID, - runtime: "node", - logLevel: "log", - // The max compute seconds a task is allowed to run. If the task run exceeds this duration, it will be stopped. - // You can override this on an individual task. - // See https://trigger.dev/docs/runs/max-duration - maxDuration: 30, - retries: { - enabledInDev: true, - default: { - maxAttempts: 3, - minTimeoutInMs: 1000, - maxTimeoutInMs: 10000, - factor: 2, - randomize: true, - }, - }, - dirs: ["jobs"], + project: env.TRIGGER_PROJECT_ID, + runtime: 'node', + logLevel: 'log', + // The max compute seconds a task is allowed to run. If the task run exceeds this duration, it will be stopped. + // You can override this on an individual task. + // See https://trigger.dev/docs/runs/max-duration + maxDuration: 30, + retries: { + enabledInDev: true, + default: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10_000, + factor: 2, + randomize: true + } + }, + dirs: ['jobs'] }); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b0fbd345d..6557862a5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,49 +1,49 @@ { - "extends": "@voidhash/tsconfig/nextjs.json", - "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "baseUrl": ".", - "paths": { - "@voidhash/lib/*": ["../../packages/lib/src/*"], - "@/pages/*": ["pages/*"], - "@/styles/*": ["styles/*"], - "@/ui/*": ["ui/*"], - "@/lib/*": ["lib/*"], - "@/features/*": ["features/*"] - }, - "downlevelIteration": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "strict": false, - "strictNullChecks": true, - "plugins": [ - { - "name": "next" - }, - { - "name": "@effect/language-service" - } - ] - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - "@/features/trpc/query-client.tsx", - "@/features/trpc/react.tsx", - "@/features/trpc/server.tsx", - "trigger.config.ts", - "vitest.integration.mts" - ], - "exclude": ["node_modules", ".next"] + "extends": "@voidhash/tsconfig/nextjs.json", + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@voidhash/lib/*": ["../../packages/lib/src/*"], + "@/pages/*": ["pages/*"], + "@/styles/*": ["styles/*"], + "@/ui/*": ["ui/*"], + "@/lib/*": ["lib/*"], + "@/features/*": ["features/*"] + }, + "downlevelIteration": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "strict": false, + "strictNullChecks": true, + "plugins": [ + { + "name": "next" + }, + { + "name": "@effect/language-service" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "@/features/trpc/query-client.tsx", + "@/features/trpc/react.tsx", + "@/features/trpc/server.tsx", + "trigger.config.ts", + "vitest.integration.mts" + ], + "exclude": ["node_modules", ".next"] } diff --git a/apps/web/vitest.integration.mts b/apps/web/vitest.integration.mts index 0b7c66144..0c4edc2ef 100644 --- a/apps/web/vitest.integration.mts +++ b/apps/web/vitest.integration.mts @@ -1,6 +1,6 @@ -import { loadEnv } from "vite"; -import { defineConfig } from "vitest/config"; -import tsconfigPaths from "vite-tsconfig-paths"; +import { loadEnv } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [tsconfigPaths()], @@ -8,18 +8,18 @@ export default defineConfig({ include: [ // TODO: Re-enable this when we release the API // "./lib/api/v1/**/*.test.ts", - "./lib/services/**/*.integration.test.ts", - "./lib/payment-providers/**/*.integration.test.ts", + './lib/services/**/*.integration.test.ts', + './lib/payment-providers/**/*.integration.test.ts' ], - reporters: ["verbose"], - pool: "threads", + reporters: ['verbose'], + pool: 'threads', poolOptions: { threads: { - singleThread: true, - }, + singleThread: true + } }, - env: loadEnv("", process.cwd(), ""), + env: loadEnv('', process.cwd(), ''), testTimeout: 60_000, - teardownTimeout: 60_000, - }, + teardownTimeout: 60_000 + } }); diff --git a/apps/web/vitest.unit.mts b/apps/web/vitest.unit.mts index bf9a82670..8ae9a7e9a 100644 --- a/apps/web/vitest.unit.mts +++ b/apps/web/vitest.unit.mts @@ -1,13 +1,13 @@ -import { loadEnv } from "vite"; -import { defineConfig } from "vitest/config"; -import tsconfigPaths from "vite-tsconfig-paths"; +import { loadEnv } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [tsconfigPaths()], test: { - include: ["./**/*.test.ts"], - exclude: ["./lib/api/v1/**/*.test.ts", "./node_modules/**"], - reporters: ["verbose"], - env: loadEnv("", process.cwd(), ""), - }, + include: ['./**/*.test.ts'], + exclude: ['./lib/api/v1/**/*.test.ts', './node_modules/**'], + reporters: ['verbose'], + env: loadEnv('', process.cwd(), '') + } }); diff --git a/biome.json b/biome.json deleted file mode 100644 index b9e31a75b..000000000 --- a/biome.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", - "formatter": { - "enabled": true, - "indentStyle": "tab" - }, - "organizeImports": { - "enabled": false - }, - "linter": { - "enabled": true, - "rules": { - "recommended": false, - "suspicious": { - "noImplicitAnyLet": "warn", - "noDuplicateObjectKeys": "warn" - }, - "performance": { - "noDelete": "error" - }, - "complexity": { - "noUselessSwitchCase": "warn", - "noUselessTypeConstraint": "warn" - }, - "correctness": { - "noUnusedImports": "warn" - } - } - }, - "javascript": { - "formatter": { - "trailingCommas": "es5" - } - }, - "files": { - "ignore": [ - "dist", - ".next", - ".svelte-kit", - "package.json", - ".contentlayer", - ".turbo", - ".nuxt", - ".source", - ".expo", - ".cache" - ] - } -} \ No newline at end of file diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 000000000..eb1d20392 --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,32 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.1.2/schema.json", + "extends": ["ultracite"], + "javascript": { + "formatter": { + "trailingCommas": "none" + } + }, + "linter": { + "rules": { + "style": { + "noNestedTernary": "off", + "useFilenamingConvention": "off", + "noDoneCallback": "off", + "noExportedImports": "off", + "useDefaultSwitchClause": "off" + }, + + "complexity": { + "noExcessiveCognitiveComplexity": "off" + }, + + "performance": { + "noNamespaceImport": "off" + }, + "nursery": { + "noNestedComponentDefinitions": "off", + "noShadow": "off" + } + } + } +} diff --git a/bump.config.ts b/bump.config.ts index cfc5179d1..0c6f038ba 100644 --- a/bump.config.ts +++ b/bump.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from "bumpp"; -import { globSync } from "tinyglobby"; +import { defineConfig } from 'bumpp'; +import { globSync } from 'tinyglobby'; export default defineConfig({ - files: globSync(["./packages/*/package.json"], { expandDirectories: false }), + files: globSync(['./packages/*/package.json'], { expandDirectories: false }) }); diff --git a/drizzle.config.ts b/drizzle.config.ts index 707961aa1..b15390c1e 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,18 +1,20 @@ -import { defineConfig } from "drizzle-kit"; +/** biome-ignore-all lint/style/noNonNullAssertion: ok in this config file */ +/** biome-ignore-all lint/complexity/useLiteralKeys: it is ok */ +import { defineConfig } from 'drizzle-kit'; export default defineConfig({ - dialect: "mysql", - schema: "./packages/db/src/schema.ts", - out: "./packages/db/src/migrations", - dbCredentials: { - ...(process.env["NODE_ENV"] === "production" - ? { - url: `mysql://${process.env["DATABASE_USERNAME"]}:${process.env["DATABASE_PASSWORD"]}@${process.env["DATABASE_HOST"]}/${process.env["DATABASE_NAME"]}?ssl={"rejectUnauthorized":true}`, - } - : { - host: process.env["DATABASE_HOST"]!, - user: process.env["DATABASE_USERNAME"]!, - database: process.env["DATABASE_NAME"]!, - password: process.env["DATABASE_PASSWORD"]!, - }), - }, + dialect: 'mysql', + schema: './packages/db/src/schema.ts', + out: './packages/db/src/migrations', + dbCredentials: { + ...(process.env['NODE_ENV'] === 'production' + ? { + url: `mysql://${process.env['DATABASE_USERNAME']}:${process.env['DATABASE_PASSWORD']}@${process.env['DATABASE_HOST']}/${process.env['DATABASE_NAME']}?ssl={"rejectUnauthorized":true}` + } + : { + host: process.env['DATABASE_HOST']!, + user: process.env['DATABASE_USERNAME']!, + database: process.env['DATABASE_NAME']!, + password: process.env['DATABASE_PASSWORD']! + }) + } }); diff --git a/examples/react-native-example/.eas/workflows/create-development-builds.yml b/examples/react-native-example/.eas/workflows/create-development-builds.yml new file mode 100644 index 000000000..fd0dbf000 --- /dev/null +++ b/examples/react-native-example/.eas/workflows/create-development-builds.yml @@ -0,0 +1,9 @@ +name: Create development builds + +jobs: + ios_device_development_build: + name: Build iOS device + type: build + params: + platform: ios + profile: development \ No newline at end of file diff --git a/examples/react-native-example/.eas/workflows/deploy-to-production.yml b/examples/react-native-example/.eas/workflows/deploy-to-production.yml new file mode 100644 index 000000000..1891db4a7 --- /dev/null +++ b/examples/react-native-example/.eas/workflows/deploy-to-production.yml @@ -0,0 +1,68 @@ +name: Deploy to production + +on: + push: + branches: ['main'] + +jobs: + fingerprint: + name: Fingerprint + type: fingerprint + # get_android_build: + # name: Check for existing android build + # needs: [fingerprint] + # type: get-build + # params: + # fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }} + # profile: production + get_ios_build: + name: Check for existing ios build + needs: [fingerprint] + type: get-build + params: + fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }} + profile: production + # build_android: + # name: Build Android + # needs: [get_android_build] + # if: ${{ !needs.get_android_build.outputs.build_id }} + # type: build + # params: + # platform: android + # profile: production + build_ios: + name: Build iOS + needs: [get_ios_build] + if: ${{ !needs.get_ios_build.outputs.build_id }} + type: build + params: + platform: ios + profile: production + # submit_android_build: + # name: Submit Android Build + # needs: [build_android] + # type: submit + # params: + # build_id: ${{ needs.build_android.outputs.build_id }} + submit_ios_build: + name: Submit iOS Build + needs: [build_ios] + type: submit + params: + build_id: ${{ needs.build_ios.outputs.build_id }} + # publish_android_update: + # name: Publish Android update + # needs: [get_android_build] + # if: ${{ needs.get_android_build.outputs.build_id }} + # type: update + # params: + # branch: production + # platform: android + publish_ios_update: + name: Publish iOS update + needs: [get_ios_build] + if: ${{ needs.get_ios_build.outputs.build_id }} + type: update + params: + branch: production + platform: ios diff --git a/examples/react-native-example/.eas/workflows/publish-preview-update.yml b/examples/react-native-example/.eas/workflows/publish-preview-update.yml new file mode 100644 index 000000000..0069c55ff --- /dev/null +++ b/examples/react-native-example/.eas/workflows/publish-preview-update.yml @@ -0,0 +1,12 @@ +name: Publish preview update + +on: + push: + branches: ['*'] + +jobs: + publish_preview_update: + name: Publish preview update + type: update + params: + branch: ${{ github.ref_name || 'test' }} diff --git a/examples/react-native-example/.gitignore b/examples/react-native-example/.gitignore new file mode 100644 index 000000000..fdcfc831b --- /dev/null +++ b/examples/react-native-example/.gitignore @@ -0,0 +1,23 @@ +node_modules/ +.expo/ +dist/ +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ + + + + +ios +android + +# macOS +.DS_Store + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* \ No newline at end of file diff --git a/examples/react-native-example/app-env.d.ts b/examples/react-native-example/app-env.d.ts new file mode 100644 index 000000000..88dc403ea --- /dev/null +++ b/examples/react-native-example/app-env.d.ts @@ -0,0 +1,2 @@ +// @ts-ignore +/// diff --git a/examples/react-native-example/app.json b/examples/react-native-example/app.json new file mode 100644 index 000000000..c1b6ab63b --- /dev/null +++ b/examples/react-native-example/app.json @@ -0,0 +1,71 @@ +{ + "expo": { + "name": "react-native-voidhash-example", + "slug": "react-native-voidhash", + "version": "1.0.0", + "scheme": "vhexample", + "web": { + "favicon": "./assets/favicon.png" + }, + "experiments": { + "tsconfigPaths": true + }, + "newArchEnabled": true, + "plugins": [ + "expo-router", + "@voidhash/react-native", + [ + "expo-splash-screen", + { + "image": "./assets/splash-icon-light.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#198CE8", + "dark": { + "image": "./assets/splash-icon-dark.png", + "backgroundColor": "#000" + } + } + ], + [ + "expo-dev-client", + { + "launchMode": "most-recent" + } + ], + "expo-localization" + ], + "orientation": "portrait", + "icon": "./assets/ios-light.png", + "userInterfaceStyle": "automatic", + "assetBundlePatterns": ["**/*"], + "ios": { + "icon": { + "light": "./assets/ios-light.png", + "dark": "./assets/ios-dark.png", + "tinted": "./assets/ios-tinted.png" + }, + "supportsTablet": true, + "bundleIdentifier": "com.voidhash.rnvhexample", + "appleTeamId": "ATKMBPDJWY", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#198CE8" + }, + "edgeToEdgeEnabled": true, + "package": "com.voidhash.rnvhexample" + }, + "extra": { + "router": {}, + "eas": { + "projectId": "e36f220f-220e-44d9-bd63-f36f1912be58" + } + }, + "owner": "voidhash" + } +} diff --git a/examples/react-native-example/app/+native-intent.tsx b/examples/react-native-example/app/+native-intent.tsx new file mode 100644 index 000000000..461aa8ef2 --- /dev/null +++ b/examples/react-native-example/app/+native-intent.tsx @@ -0,0 +1,8 @@ +import { expoRouterWithVoidhashCallback } from '@voidhash/react-native'; + +export function redirectSystemPath(options: { + path: string; + initial: boolean; +}) { + return expoRouterWithVoidhashCallback(options); +} diff --git a/examples/react-native-example/app/+not-found.tsx b/examples/react-native-example/app/+not-found.tsx new file mode 100644 index 000000000..3d8d3d108 --- /dev/null +++ b/examples/react-native-example/app/+not-found.tsx @@ -0,0 +1,29 @@ +import { Link, Stack } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function NotFoundScreen() { + return ( + <> + + + This screen doesn't exist. + + Go to home screen! + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20 + }, + link: { + marginTop: 15, + paddingVertical: 15 + } +}); diff --git a/examples/react-native-example/app/_layout.tsx b/examples/react-native-example/app/_layout.tsx new file mode 100644 index 000000000..735c72421 --- /dev/null +++ b/examples/react-native-example/app/_layout.tsx @@ -0,0 +1,52 @@ +import { Stack } from 'expo-router'; +import 'react-native-reanimated'; +import 'fast-text-encoding'; +import '../global.css'; + +import { StatusBar } from 'expo-status-bar'; +import { voidhash } from 'utils/voidhash/client'; + +// Prevent the splash screen from auto-hiding before asset loading is complete. +// SplashScreen.preventAutoHideAsync(); + +export default function RootLayout() { + // useEffect(() => { + // SplashScreen.hideAsync(); + // }, []); + + return ( + <> + + + + + + + + + + + + ); +} diff --git a/examples/react-native-example/app/index.tsx b/examples/react-native-example/app/index.tsx new file mode 100644 index 000000000..c5aee2ead --- /dev/null +++ b/examples/react-native-example/app/index.tsx @@ -0,0 +1,94 @@ +import { MenuItem } from 'components/menu-item'; +import { useRouter } from 'expo-router'; +import { ActivityIndicator, Image, Text, View } from 'react-native'; +import { fakeAuthService, useCurrentUser } from 'utils/fake-auth-service'; +import { cn } from 'utils/lib'; +import { voidhash } from 'utils/voidhash/client'; +import { Logo } from '../components/logo'; + +export default function HomeScreen() { + const router = useRouter(); + + // Mock authentication + const { user, isLoading } = useCurrentUser(); + + // Signs out the user + const handleSignOut = async () => { + await fakeAuthService.signOut(); + await voidhash.client.signOut(); + }; + + // Resets the Voidhash cache. This is useful for testing. + const handleResetCache = () => { + voidhash.client.resetCache(); + }; + + if (isLoading) { + return ( + + + + ); + } + + // If no active subscription, show the paywall + return ( + + + + Playground + + + + {user && ( + <> + + + + {user.name} + {user.email} + + + + + )} + + { + router.push('/menu/sign-in'); + }} + title={user ? 'Switch account' : 'Sign in'} + /> + + + router.push('/menu/paywall')} + title="Paywall" + /> + router.push('/menu/customer')} + title="Customer" + /> + + + + + + ); +} diff --git a/examples/react-native-example/app/menu/customer.tsx b/examples/react-native-example/app/menu/customer.tsx new file mode 100644 index 000000000..0bfddff58 --- /dev/null +++ b/examples/react-native-example/app/menu/customer.tsx @@ -0,0 +1,46 @@ +import { Button } from 'components/button'; +import { Platform, Text, View } from 'react-native'; +import { voidhash } from 'utils/voidhash/client'; + +export default function HomeScreen() { + const { client } = voidhash.useVoidhash(); + const { + data: customer, + isLoading: isCustomerLoading, + error: customerError + } = voidhash.useCurrentCustomer(); + + if (isCustomerLoading) { + return null; + } + + // If no active subscription, show the paywall + return ( + + + Customer + + {JSON.stringify(customer, null, 2)} + + + {JSON.stringify(customerError, null, 2)} + + + {Platform.OS === 'ios' && ( + +