Add ModeTemplate with Svelte 5 frontend and components
- Create reusable components: auth, api-client, shared-types - ModeTemplate with Hono backend and SvelteKit frontend - Auth store refactored to class-based Svelte 5 pattern - SSR-safe hydrate() pattern for localStorage access - Frontend builds successfully with Svelte 5 runes
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# API Client Component
|
||||
|
||||
Frontend helper for making authenticated requests to the backend.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createApiClient } from './components/api-client';
|
||||
import { STORAGE_KEYS } from './components/auth';
|
||||
|
||||
// Create client with token getter
|
||||
const api = createApiClient({
|
||||
baseUrl: '/api', // or 'http://localhost:3005/api'
|
||||
getToken: () => localStorage.getItem(STORAGE_KEYS.PB_TOKEN),
|
||||
});
|
||||
|
||||
// Make requests
|
||||
const result = await api.get<User[]>('/users');
|
||||
|
||||
if (result.ok) {
|
||||
console.log(result.data); // User[]
|
||||
} else {
|
||||
console.error(result.error); // Error message
|
||||
}
|
||||
|
||||
// POST with body
|
||||
const created = await api.post<Job>('/jobs', {
|
||||
title: 'New Job',
|
||||
status: 'open',
|
||||
});
|
||||
```
|
||||
|
||||
## Methods
|
||||
|
||||
- `api.get<T>(path)` - GET request
|
||||
- `api.post<T>(path, body?)` - POST request
|
||||
- `api.put<T>(path, body?)` - PUT request
|
||||
- `api.patch<T>(path, body?)` - PATCH request
|
||||
- `api.delete<T>(path)` - DELETE request
|
||||
|
||||
## Response Type
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T> {
|
||||
ok: boolean; // true if status 2xx
|
||||
status: number; // HTTP status code
|
||||
data?: T; // Response data (if ok)
|
||||
error?: string; // Error message (if not ok)
|
||||
}
|
||||
```
|
||||
|
||||
## Copying to a Mode
|
||||
|
||||
1. Copy `components/api-client/` to your mode's `frontend/lib/api-client/`
|
||||
2. Update imports
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - fully self-contained. No package.json needed - inherits from consuming project.
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* API Client Component
|
||||
*
|
||||
* Frontend helper for making authenticated requests to the backend.
|
||||
* Uses PocketBase token from auth session.
|
||||
*/
|
||||
|
||||
// Header constants (inline to avoid cross-component dependency)
|
||||
const HEADERS = {
|
||||
AUTH: 'Authorization',
|
||||
CONTENT_TYPE: 'Content-Type',
|
||||
} as const;
|
||||
|
||||
const CONTENT_TYPES = {
|
||||
JSON: 'application/json',
|
||||
} as const;
|
||||
|
||||
function authHeader(token: string): string {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ApiClientConfig {
|
||||
baseUrl: string;
|
||||
getToken: () => string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API client instance
|
||||
*/
|
||||
export function createApiClient(config: ApiClientConfig) {
|
||||
const { baseUrl, getToken } = config;
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<ApiResponse<T>> {
|
||||
const token = getToken();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
[HEADERS.CONTENT_TYPE]: CONTENT_TYPES.JSON,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers[HEADERS.AUTH] = authHeader(token);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = response.headers.get('content-type')?.includes('application/json')
|
||||
? await response.json()
|
||||
: await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
error: typeof data === 'string' ? data : data.message || 'Request failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
data: data as T,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: err instanceof Error ? err.message : 'Network error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiClient = ReturnType<typeof createApiClient>;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createApiClient } from './api-client';
|
||||
export type { ApiResponse, ApiClientConfig, ApiClient } from './api-client';
|
||||
@@ -0,0 +1,84 @@
|
||||
# Auth Component
|
||||
|
||||
Microsoft OAuth authentication that returns user info and a PocketBase token.
|
||||
|
||||
**Users must already exist in PocketBase - no user creation. Access denied if user not found.**
|
||||
|
||||
## What it does
|
||||
|
||||
1. Redirects user to Microsoft login
|
||||
2. Exchanges auth code for access token
|
||||
3. Gets user's email and display name from Microsoft Graph
|
||||
4. Checks if user exists in PocketBase (denies access if not)
|
||||
5. Returns PocketBase auth token
|
||||
|
||||
## Environment Variables Used
|
||||
|
||||
These variables exist in `/home/admin/secrets/.env`:
|
||||
|
||||
```
|
||||
MICROSOFT_CLIENT_ID
|
||||
MICROSOFT_CLIENT_SECRET
|
||||
MICROSOFT_TENANT_ID
|
||||
MICROSOFT_REDIRECT_URI
|
||||
MICROSOFT_SCOPES
|
||||
PB_URL
|
||||
```
|
||||
|
||||
## Usage in Hono Backend
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
import { getAuthUrl, handleCallback, getConfigFromEnv } from './components/auth';
|
||||
|
||||
const app = new Hono();
|
||||
const config = getConfigFromEnv();
|
||||
|
||||
// Redirect to Microsoft login
|
||||
app.get('/auth/login', (c) => {
|
||||
return c.redirect(getAuthUrl(config));
|
||||
});
|
||||
|
||||
// Handle callback from Microsoft
|
||||
app.get('/auth/callback', async (c) => {
|
||||
const code = c.req.query('code');
|
||||
|
||||
if (!code) {
|
||||
return c.json({ error: 'No authorization code' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handleCallback(code, config);
|
||||
|
||||
// result contains:
|
||||
// - email: string
|
||||
// - displayName: string
|
||||
// - pocketbaseToken: string
|
||||
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
// User doesn't exist or auth failed
|
||||
return c.json({ error: String(err) }, 403);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Copying to a Mode
|
||||
|
||||
1. Copy the entire `components/auth/` folder to your mode's `backend/modules/auth/`
|
||||
2. Update imports in your server
|
||||
3. Environment variables are loaded from secrets at runtime
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - uses native fetch API (Bun/Node 18+). No package.json needed - inherits from consuming project.
|
||||
|
||||
## Returns
|
||||
|
||||
```typescript
|
||||
interface AuthResult {
|
||||
email: string; // User's email from Microsoft
|
||||
displayName: string; // User's display name from Microsoft
|
||||
pocketbaseToken: string; // Auth token for PocketBase API calls
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
getAuthUrl,
|
||||
handleCallback,
|
||||
exchangeCodeForToken,
|
||||
getMicrosoftUserInfo,
|
||||
getPocketBaseToken,
|
||||
getConfigFromEnv,
|
||||
} from './microsoft-oauth';
|
||||
|
||||
export type {
|
||||
AuthResult,
|
||||
OAuthConfig
|
||||
} from './microsoft-oauth';
|
||||
|
||||
export {
|
||||
HEADERS,
|
||||
CONTENT_TYPES,
|
||||
STORAGE_KEYS,
|
||||
pbAuthHeader,
|
||||
pbHeaders,
|
||||
isSessionExpired,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
MicrosoftTokens,
|
||||
PocketBaseTokens,
|
||||
AuthSession,
|
||||
} from './types';
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Microsoft OAuth Module
|
||||
*
|
||||
* Handles Microsoft OAuth flow to authenticate users and retrieve:
|
||||
* - User's email
|
||||
* - User's display name
|
||||
* - PocketBase auth token (user must already exist)
|
||||
*
|
||||
* Environment Variables Used:
|
||||
* - MICROSOFT_CLIENT_ID
|
||||
* - MICROSOFT_CLIENT_SECRET
|
||||
* - MICROSOFT_TENANT_ID
|
||||
* - MICROSOFT_REDIRECT_URI
|
||||
* - MICROSOFT_SCOPES
|
||||
* - PB_URL
|
||||
*/
|
||||
|
||||
interface MicrosoftTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
interface MicrosoftUserInfo {
|
||||
id: string;
|
||||
displayName: string;
|
||||
mail: string;
|
||||
userPrincipalName: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
email: string;
|
||||
displayName: string;
|
||||
pocketbaseToken: string;
|
||||
}
|
||||
|
||||
export interface OAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
tenantId: string;
|
||||
redirectUri: string;
|
||||
scopes: string;
|
||||
pbUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build config from process.env
|
||||
*/
|
||||
export function getConfigFromEnv(): OAuthConfig {
|
||||
const clientId = process.env.MICROSOFT_CLIENT_ID;
|
||||
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
|
||||
const tenantId = process.env.MICROSOFT_TENANT_ID;
|
||||
const redirectUri = process.env.MICROSOFT_REDIRECT_URI;
|
||||
const scopes = process.env.MICROSOFT_SCOPES;
|
||||
const pbUrl = process.env.PB_URL;
|
||||
|
||||
if (!clientId || !clientSecret || !tenantId || !redirectUri || !scopes || !pbUrl) {
|
||||
throw new Error('Missing required OAuth environment variables');
|
||||
}
|
||||
|
||||
return { clientId, clientSecret, tenantId, redirectUri, scopes, pbUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the Microsoft OAuth authorization URL
|
||||
*/
|
||||
export function getAuthUrl(config: OAuthConfig): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: config.redirectUri,
|
||||
response_mode: 'query',
|
||||
scope: config.scopes,
|
||||
state: crypto.randomUUID(),
|
||||
});
|
||||
|
||||
return `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/authorize?${params}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
export async function exchangeCodeForToken(
|
||||
code: string,
|
||||
config: OAuthConfig
|
||||
): Promise<MicrosoftTokenResponse> {
|
||||
const response = await fetch(
|
||||
`https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
scope: config.scopes,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info from Microsoft Graph API
|
||||
*/
|
||||
export async function getMicrosoftUserInfo(
|
||||
accessToken: string
|
||||
): Promise<MicrosoftUserInfo> {
|
||||
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to get user info: ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate existing user with PocketBase
|
||||
* User MUST already exist - no user creation
|
||||
*/
|
||||
export async function getPocketBaseToken(
|
||||
email: string,
|
||||
pbUrl: string
|
||||
): Promise<string> {
|
||||
// Look up user by email - user must exist
|
||||
const searchResponse = await fetch(
|
||||
`${pbUrl}/api/collections/users/records?filter=(email='${encodeURIComponent(email)}')`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!searchResponse.ok) {
|
||||
throw new Error('Failed to query PocketBase');
|
||||
}
|
||||
|
||||
const searchData = await searchResponse.json();
|
||||
|
||||
if (!searchData.items || searchData.items.length === 0) {
|
||||
throw new Error('Access denied: User does not exist');
|
||||
}
|
||||
|
||||
// User exists - authenticate via OAuth
|
||||
const authResponse = await fetch(`${pbUrl}/api/collections/users/auth-with-oauth2`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: 'microsoft',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!authResponse.ok) {
|
||||
const error = await authResponse.text();
|
||||
throw new Error(`PocketBase auth failed: ${error}`);
|
||||
}
|
||||
|
||||
const data = await authResponse.json();
|
||||
return data.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OAuth flow: code -> user info + PocketBase token
|
||||
* Denies access if user doesn't exist in PocketBase
|
||||
*/
|
||||
export async function handleCallback(
|
||||
code: string,
|
||||
config: OAuthConfig
|
||||
): Promise<AuthResult> {
|
||||
// 1. Exchange code for Microsoft access token
|
||||
const tokenResponse = await exchangeCodeForToken(code, config);
|
||||
|
||||
// 2. Get user info from Microsoft Graph
|
||||
const userInfo = await getMicrosoftUserInfo(tokenResponse.access_token);
|
||||
|
||||
const email = userInfo.mail || userInfo.userPrincipalName;
|
||||
const displayName = userInfo.displayName;
|
||||
|
||||
// 3. Get PocketBase auth token (user must exist)
|
||||
const pocketbaseToken = await getPocketBaseToken(email, config.pbUrl);
|
||||
|
||||
return {
|
||||
email,
|
||||
displayName,
|
||||
pocketbaseToken,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Auth Constants & Types
|
||||
*
|
||||
* Standard naming conventions for auth-related values.
|
||||
* Use these throughout all modes to maintain consistency.
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// TOKEN TYPES
|
||||
// ============================================
|
||||
|
||||
/** Token received from Microsoft OAuth */
|
||||
export interface MicrosoftTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/** Token received from PocketBase */
|
||||
export interface PocketBaseTokens {
|
||||
token: string;
|
||||
// PB tokens don't have separate refresh - they expire and re-auth is needed
|
||||
}
|
||||
|
||||
/** Combined auth state after successful login */
|
||||
export interface AuthSession {
|
||||
email: string;
|
||||
displayName: string;
|
||||
pbToken: string; // PocketBase auth token
|
||||
msAccessToken?: string; // Microsoft access token (if needed for Graph API calls)
|
||||
expiresAt?: number; // Unix timestamp when session expires
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HEADER NAMES
|
||||
// ============================================
|
||||
|
||||
export const HEADERS = {
|
||||
/** Authorization header for PocketBase API calls */
|
||||
PB_AUTH: 'Authorization',
|
||||
|
||||
/** Content type for JSON requests */
|
||||
CONTENT_TYPE: 'Content-Type',
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// HEADER VALUES
|
||||
// ============================================
|
||||
|
||||
export const CONTENT_TYPES = {
|
||||
JSON: 'application/json',
|
||||
FORM: 'application/x-www-form-urlencoded',
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// COOKIE/STORAGE KEYS
|
||||
// ============================================
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
/** PocketBase token storage key */
|
||||
PB_TOKEN: 'pb_token',
|
||||
|
||||
/** Session data storage key */
|
||||
SESSION: 'auth_session',
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create Authorization header value for PocketBase
|
||||
*/
|
||||
export function pbAuthHeader(token: string): string {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create headers object for PocketBase API calls
|
||||
*/
|
||||
export function pbHeaders(token?: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
[HEADERS.CONTENT_TYPE]: CONTENT_TYPES.JSON,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers[HEADERS.PB_AUTH] = pbAuthHeader(token);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is expired
|
||||
*/
|
||||
export function isSessionExpired(session: AuthSession): boolean {
|
||||
if (!session.expiresAt) return false;
|
||||
return Date.now() > session.expiresAt;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Shared Types
|
||||
|
||||
Common types used by both frontend and backend. Keeps API contracts in sync.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import type { User, ApiResult, PaginatedResponse } from './components/shared-types';
|
||||
|
||||
// Backend endpoint
|
||||
app.get('/api/users', async (c): Promise<ApiResult<PaginatedResponse<User>>> => {
|
||||
const users = await getUsers();
|
||||
return c.json({ success: true, data: users });
|
||||
});
|
||||
|
||||
// Frontend call
|
||||
const result = await api.get<ApiResult<PaginatedResponse<User>>>('/users');
|
||||
if (result.ok && result.data?.success) {
|
||||
const users = result.data.data.items;
|
||||
}
|
||||
```
|
||||
|
||||
## Types Included
|
||||
|
||||
**User/Auth:**
|
||||
- `User` - User record
|
||||
- `AuthState` - Frontend auth state
|
||||
|
||||
**API Responses:**
|
||||
- `SuccessResponse<T>` - `{ success: true, data: T }`
|
||||
- `ErrorResponse` - `{ success: false, error: string }`
|
||||
- `ApiResult<T>` - Either success or error
|
||||
- `PaginatedResponse<T>` - List with pagination metadata
|
||||
|
||||
**Request Params:**
|
||||
- `PaginationParams` - `{ page?, perPage? }`
|
||||
- `SortParams` - `{ sort?, order? }`
|
||||
- `ListParams` - Combined pagination + sort
|
||||
|
||||
**Utility Types:**
|
||||
- `CreateInput<T>` - Omits id and timestamps for creating
|
||||
- `UpdateInput<T>` - Partial without id/timestamps for updating
|
||||
|
||||
## Copying to a Mode
|
||||
|
||||
1. Copy `components/shared-types/` to your mode root as `types/`
|
||||
2. Import from `./types` in both frontend and backend
|
||||
|
||||
## Dependencies
|
||||
|
||||
None - pure TypeScript types.
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
User,
|
||||
AuthState,
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
ApiResult,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
SortParams,
|
||||
ListParams,
|
||||
PartialBy,
|
||||
RequiredBy,
|
||||
CreateInput,
|
||||
UpdateInput,
|
||||
} from './types';
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Shared Types
|
||||
*
|
||||
* Common types used by both frontend and backend.
|
||||
* Copy this to any Mode to ensure type consistency.
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// USER / AUTH
|
||||
// ============================================
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API RESPONSES
|
||||
// ============================================
|
||||
|
||||
/** Standard success response */
|
||||
export interface SuccessResponse<T = unknown> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** Standard error response */
|
||||
export interface ErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** Combined response type */
|
||||
export type ApiResult<T = unknown> = SuccessResponse<T> | ErrorResponse;
|
||||
|
||||
/** Paginated list response */
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REQUEST TYPES
|
||||
// ============================================
|
||||
|
||||
/** Standard pagination params */
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
/** Standard sort params */
|
||||
export interface SortParams {
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** Combined list params */
|
||||
export type ListParams = PaginationParams & SortParams;
|
||||
|
||||
// ============================================
|
||||
// UTILITY TYPES
|
||||
// ============================================
|
||||
|
||||
/** Make specific properties optional */
|
||||
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
/** Make specific properties required */
|
||||
export type RequiredBy<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
|
||||
/** Create/Update types - id is optional for create */
|
||||
export type CreateInput<T extends { id: string }> = Omit<T, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
export type UpdateInput<T extends { id: string }> = Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>;
|
||||
Reference in New Issue
Block a user