Initial Phoenix Sankofa Cloud setup
- Complete project structure with Next.js frontend - GraphQL API backend with Apollo Server - Portal application with NextAuth - Crossplane Proxmox provider - GitOps configurations - CI/CD pipelines - Testing infrastructure (Vitest, Jest, Go tests) - Error handling and monitoring - Security hardening - UI component library - Documentation
This commit is contained in:
12
api/src/context.ts
Normal file
12
api/src/context.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { FastifyRequest } from 'fastify'
|
||||
import { Context } from './types/context'
|
||||
import { getDb } from './db'
|
||||
|
||||
export async function createContext(request: FastifyRequest): Promise<Context> {
|
||||
return {
|
||||
request,
|
||||
user: (request as any).user, // Set by auth middleware
|
||||
db: getDb(),
|
||||
}
|
||||
}
|
||||
|
||||
32
api/src/db/index.ts
Normal file
32
api/src/db/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Pool } from 'pg'
|
||||
|
||||
let pool: Pool | null = null
|
||||
|
||||
export function getDb(): Pool {
|
||||
if (!pool) {
|
||||
pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
database: process.env.DB_NAME || 'sankofa',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
})
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err)
|
||||
})
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end()
|
||||
pool = null
|
||||
}
|
||||
}
|
||||
|
||||
64
api/src/db/schema.sql
Normal file
64
api/src/db/schema.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
-- Phoenix Sankofa Cloud Database Schema
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'USER' CHECK (role IN ('ADMIN', 'USER', 'VIEWER')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Sites table
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
region VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE', 'MAINTENANCE')),
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Resources table
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL CHECK (type IN ('VM', 'CONTAINER', 'STORAGE', 'NETWORK')),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'PROVISIONING', 'RUNNING', 'STOPPED', 'ERROR', 'DELETING')),
|
||||
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_site_id ON resources(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_status ON resources(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
-- Update timestamp trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Triggers
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_sites_updated_at BEFORE UPDATE ON sites
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_resources_updated_at BEFORE UPDATE ON resources
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
39
api/src/middleware/auth.ts
Normal file
39
api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { User } from '../types/context'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||
|
||||
export async function authMiddleware(
|
||||
request: FastifyRequest,
|
||||
_reply: FastifyReply
|
||||
) {
|
||||
// Skip auth for health check and GraphQL introspection
|
||||
if (request.url === '/health' || request.method === 'OPTIONS') {
|
||||
return
|
||||
}
|
||||
|
||||
// Get token from Authorization header
|
||||
const authHeader = request.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
// Allow unauthenticated requests - GraphQL will handle auth per query/mutation
|
||||
return
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7)
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any
|
||||
// Attach user to request
|
||||
;(request as any).user = {
|
||||
id: decoded.id,
|
||||
email: decoded.email,
|
||||
name: decoded.name,
|
||||
role: decoded.role,
|
||||
} as User
|
||||
} catch (error) {
|
||||
// Invalid token - let GraphQL resolvers handle it
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
9
api/src/schema/index.ts
Normal file
9
api/src/schema/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { makeExecutableSchema } from '@graphql-tools/schema'
|
||||
import { typeDefs } from './typeDefs'
|
||||
import { resolvers } from './resolvers'
|
||||
|
||||
export const schema = makeExecutableSchema({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
})
|
||||
|
||||
181
api/src/schema/resolvers.ts
Normal file
181
api/src/schema/resolvers.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { Context } from '../types/context'
|
||||
import * as resourceService from '../services/resource'
|
||||
import * as siteService from '../services/site'
|
||||
import * as userService from '../services/user'
|
||||
import * as authService from '../services/auth'
|
||||
|
||||
export const resolvers = {
|
||||
Query: {
|
||||
health: () => ({
|
||||
status: 'ok',
|
||||
timestamp: new Date(),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
}),
|
||||
|
||||
resources: async (_: unknown, args: { filter?: any }, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
return resourceService.getResources(context, args.filter)
|
||||
},
|
||||
|
||||
resource: async (_: unknown, args: { id: string }, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
return resourceService.getResource(context, args.id)
|
||||
},
|
||||
|
||||
sites: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
return siteService.getSites(context)
|
||||
},
|
||||
|
||||
site: async (_: unknown, args: { id: string }, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
return siteService.getSite(context, args.id)
|
||||
},
|
||||
|
||||
me: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
return context.user
|
||||
},
|
||||
|
||||
users: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user || context.user.role !== 'ADMIN') {
|
||||
throw new GraphQLError('Admin access required', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
}
|
||||
return userService.getUsers(context)
|
||||
},
|
||||
|
||||
user: async (_: unknown, args: { id: string }, context: Context) => {
|
||||
if (!context.user || context.user.role !== 'ADMIN') {
|
||||
throw new GraphQLError('Admin access required', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
}
|
||||
return userService.getUser(context, args.id)
|
||||
},
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
login: async (_: unknown, args: { email: string; password: string }) => {
|
||||
return authService.login(args.email, args.password)
|
||||
},
|
||||
|
||||
logout: async (_: unknown, __: unknown, _context: Context) => {
|
||||
// In a real implementation, invalidate the token
|
||||
return true
|
||||
},
|
||||
|
||||
createResource: async (
|
||||
_: unknown,
|
||||
args: { input: any },
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
return resourceService.createResource(context, args.input)
|
||||
},
|
||||
|
||||
updateResource: async (
|
||||
_: unknown,
|
||||
args: { id: string; input: any },
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
return resourceService.updateResource(context, args.id, args.input)
|
||||
},
|
||||
|
||||
deleteResource: async (
|
||||
_: unknown,
|
||||
args: { id: string },
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
return resourceService.deleteResource(context, args.id)
|
||||
},
|
||||
|
||||
createUser: async (
|
||||
_: unknown,
|
||||
args: { input: any },
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user || context.user.role !== 'ADMIN') {
|
||||
throw new GraphQLError('Admin access required', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
}
|
||||
return userService.createUser(context, args.input)
|
||||
},
|
||||
|
||||
updateUser: async (
|
||||
_: unknown,
|
||||
args: { id: string; input: any },
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user || context.user.role !== 'ADMIN') {
|
||||
throw new GraphQLError('Admin access required', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
}
|
||||
return userService.updateUser(context, args.id, args.input)
|
||||
},
|
||||
|
||||
deleteUser: async (
|
||||
_: unknown,
|
||||
args: { id: string },
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user || context.user.role !== 'ADMIN') {
|
||||
throw new GraphQLError('Admin access required', {
|
||||
extensions: { code: 'FORBIDDEN' },
|
||||
})
|
||||
}
|
||||
return userService.deleteUser(context, args.id)
|
||||
},
|
||||
},
|
||||
|
||||
Resource: {
|
||||
site: async (resource: any, __: unknown, context: Context) => {
|
||||
return siteService.getSite(context, resource.siteId)
|
||||
},
|
||||
},
|
||||
|
||||
Site: {
|
||||
resources: async (site: any, __: unknown, context: Context) => {
|
||||
return resourceService.getResources(context, { siteId: site.id })
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
146
api/src/schema/typeDefs.ts
Normal file
146
api/src/schema/typeDefs.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export const typeDefs = gql`
|
||||
scalar DateTime
|
||||
scalar JSON
|
||||
|
||||
type Query {
|
||||
# Health check
|
||||
health: HealthStatus
|
||||
|
||||
# Resources
|
||||
resources(filter: ResourceFilter): [Resource!]!
|
||||
resource(id: ID!): Resource
|
||||
|
||||
# Sites
|
||||
sites: [Site!]!
|
||||
site(id: ID!): Site
|
||||
|
||||
# Users
|
||||
me: User
|
||||
users: [User!]!
|
||||
user(id: ID!): User
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
# Authentication
|
||||
login(email: String!, password: String!): AuthPayload!
|
||||
logout: Boolean!
|
||||
|
||||
# Resources
|
||||
createResource(input: CreateResourceInput!): Resource!
|
||||
updateResource(id: ID!, input: UpdateResourceInput!): Resource!
|
||||
deleteResource(id: ID!): Boolean!
|
||||
|
||||
# Users
|
||||
createUser(input: CreateUserInput!): User!
|
||||
updateUser(id: ID!, input: UpdateUserInput!): User!
|
||||
deleteUser(id: ID!): Boolean!
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
resourceUpdated(id: ID!): Resource!
|
||||
resourceCreated: Resource!
|
||||
resourceDeleted(id: ID!): ID!
|
||||
}
|
||||
|
||||
type HealthStatus {
|
||||
status: String!
|
||||
timestamp: DateTime!
|
||||
version: String!
|
||||
}
|
||||
|
||||
type Resource {
|
||||
id: ID!
|
||||
name: String!
|
||||
type: ResourceType!
|
||||
status: ResourceStatus!
|
||||
site: Site!
|
||||
metadata: JSON
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type Site {
|
||||
id: ID!
|
||||
name: String!
|
||||
region: String!
|
||||
status: SiteStatus!
|
||||
resources: [Resource!]!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
email: String!
|
||||
name: String!
|
||||
role: UserRole!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type AuthPayload {
|
||||
token: String!
|
||||
user: User!
|
||||
}
|
||||
|
||||
enum ResourceType {
|
||||
VM
|
||||
CONTAINER
|
||||
STORAGE
|
||||
NETWORK
|
||||
}
|
||||
|
||||
enum ResourceStatus {
|
||||
PENDING
|
||||
PROVISIONING
|
||||
RUNNING
|
||||
STOPPED
|
||||
ERROR
|
||||
DELETING
|
||||
}
|
||||
|
||||
enum SiteStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
MAINTENANCE
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
USER
|
||||
VIEWER
|
||||
}
|
||||
|
||||
input ResourceFilter {
|
||||
type: ResourceType
|
||||
status: ResourceStatus
|
||||
siteId: ID
|
||||
}
|
||||
|
||||
input CreateResourceInput {
|
||||
name: String!
|
||||
type: ResourceType!
|
||||
siteId: ID!
|
||||
metadata: JSON
|
||||
}
|
||||
|
||||
input UpdateResourceInput {
|
||||
name: String
|
||||
metadata: JSON
|
||||
}
|
||||
|
||||
input CreateUserInput {
|
||||
email: String!
|
||||
name: String!
|
||||
password: String!
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
input UpdateUserInput {
|
||||
name: String
|
||||
role: UserRole
|
||||
}
|
||||
`
|
||||
|
||||
52
api/src/server.ts
Normal file
52
api/src/server.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'dotenv/config'
|
||||
import Fastify from 'fastify'
|
||||
import { ApolloServer } from '@apollo/server'
|
||||
import { fastifyApolloDrainPlugin, fastifyApolloHandler } from '@as-integrations/fastify'
|
||||
import { schema } from './schema'
|
||||
import { createContext } from './context'
|
||||
import { authMiddleware } from './middleware/auth'
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: true,
|
||||
})
|
||||
|
||||
// Register authentication middleware
|
||||
fastify.addHook('onRequest', authMiddleware)
|
||||
|
||||
// Create Apollo Server
|
||||
const apolloServer = new ApolloServer({
|
||||
schema,
|
||||
plugins: [fastifyApolloDrainPlugin(fastify)],
|
||||
})
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
// Start Apollo Server
|
||||
await apolloServer.start()
|
||||
|
||||
// Register GraphQL route
|
||||
fastify.post('/graphql', async (request, reply) => {
|
||||
return fastifyApolloHandler(apolloServer, {
|
||||
context: async () => createContext(request),
|
||||
})(request, reply)
|
||||
})
|
||||
|
||||
// Health check endpoint
|
||||
fastify.get('/health', async () => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() }
|
||||
})
|
||||
|
||||
// Start Fastify server
|
||||
const port = parseInt(process.env.PORT || '4000', 10)
|
||||
const host = process.env.HOST || '0.0.0.0'
|
||||
|
||||
await fastify.listen({ port, host })
|
||||
console.log(`🚀 Server ready at http://${host}:${port}/graphql`)
|
||||
} catch (err) {
|
||||
fastify.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
startServer()
|
||||
|
||||
55
api/src/services/auth.ts
Normal file
55
api/src/services/auth.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getDb } from '../db'
|
||||
import { User } from '../types/context'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'
|
||||
|
||||
export interface AuthPayload {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<AuthPayload> {
|
||||
const db = getDb()
|
||||
const result = await db.query(
|
||||
'SELECT id, email, name, password_hash, role, created_at, updated_at FROM users WHERE email = $1',
|
||||
[email]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Invalid email or password')
|
||||
}
|
||||
|
||||
const user = result.rows[0]
|
||||
const isValid = await bcrypt.compare(password, user.password_hash)
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid email or password')
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
)
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
105
api/src/services/resource.ts
Normal file
105
api/src/services/resource.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Context } from '../types/context'
|
||||
|
||||
export async function getResources(context: Context, filter?: any) {
|
||||
const db = context.db
|
||||
let query = 'SELECT * FROM resources WHERE 1=1'
|
||||
const params: any[] = []
|
||||
let paramCount = 1
|
||||
|
||||
if (filter?.type) {
|
||||
query += ` AND type = $${paramCount}`
|
||||
params.push(filter.type)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (filter?.status) {
|
||||
query += ` AND status = $${paramCount}`
|
||||
params.push(filter.status)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (filter?.siteId) {
|
||||
query += ` AND site_id = $${paramCount}`
|
||||
params.push(filter.siteId)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC'
|
||||
|
||||
const result = await db.query(query, params)
|
||||
return result.rows.map(mapResource)
|
||||
}
|
||||
|
||||
export async function getResource(context: Context, id: string) {
|
||||
const db = context.db
|
||||
const result = await db.query('SELECT * FROM resources WHERE id = $1', [id])
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Resource not found')
|
||||
}
|
||||
|
||||
return mapResource(result.rows[0])
|
||||
}
|
||||
|
||||
export async function createResource(context: Context, input: any) {
|
||||
const db = context.db
|
||||
const result = await db.query(
|
||||
`INSERT INTO resources (name, type, status, site_id, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[input.name, input.type, 'PENDING', input.siteId, JSON.stringify(input.metadata || {})]
|
||||
)
|
||||
|
||||
return mapResource(result.rows[0])
|
||||
}
|
||||
|
||||
export async function updateResource(context: Context, id: string, input: any) {
|
||||
const db = context.db
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
let paramCount = 1
|
||||
|
||||
if (input.name !== undefined) {
|
||||
updates.push(`name = $${paramCount}`)
|
||||
params.push(input.name)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (input.metadata !== undefined) {
|
||||
updates.push(`metadata = $${paramCount}`)
|
||||
params.push(JSON.stringify(input.metadata))
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return getResource(context, id)
|
||||
}
|
||||
|
||||
params.push(id)
|
||||
const result = await db.query(
|
||||
`UPDATE resources SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
|
||||
params
|
||||
)
|
||||
|
||||
return mapResource(result.rows[0])
|
||||
}
|
||||
|
||||
export async function deleteResource(context: Context, id: string) {
|
||||
const db = context.db
|
||||
await db.query('DELETE FROM resources WHERE id = $1', [id])
|
||||
return true
|
||||
}
|
||||
|
||||
function mapResource(row: any) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
siteId: row.site_id,
|
||||
metadata: row.metadata || {},
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
31
api/src/services/site.ts
Normal file
31
api/src/services/site.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Context } from '../types/context'
|
||||
|
||||
export async function getSites(context: Context) {
|
||||
const db = context.db
|
||||
const result = await db.query('SELECT * FROM sites ORDER BY created_at DESC')
|
||||
return result.rows.map(mapSite)
|
||||
}
|
||||
|
||||
export async function getSite(context: Context, id: string) {
|
||||
const db = context.db
|
||||
const result = await db.query('SELECT * FROM sites WHERE id = $1', [id])
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Site not found')
|
||||
}
|
||||
|
||||
return mapSite(result.rows[0])
|
||||
}
|
||||
|
||||
function mapSite(row: any) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
region: row.region,
|
||||
status: row.status,
|
||||
metadata: row.metadata || {},
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
88
api/src/services/user.ts
Normal file
88
api/src/services/user.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { Context } from '../types/context'
|
||||
|
||||
export async function getUsers(context: Context) {
|
||||
const db = context.db
|
||||
const result = await db.query(
|
||||
'SELECT id, email, name, role, created_at, updated_at FROM users ORDER BY created_at DESC'
|
||||
)
|
||||
return result.rows.map(mapUser)
|
||||
}
|
||||
|
||||
export async function getUser(context: Context, id: string) {
|
||||
const db = context.db
|
||||
const result = await db.query(
|
||||
'SELECT id, email, name, role, created_at, updated_at FROM users WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found')
|
||||
}
|
||||
|
||||
return mapUser(result.rows[0])
|
||||
}
|
||||
|
||||
export async function createUser(context: Context, input: any) {
|
||||
const db = context.db
|
||||
const passwordHash = await bcrypt.hash(input.password, 10)
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO users (email, name, password_hash, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, name, role, created_at, updated_at`,
|
||||
[input.email, input.name, passwordHash, input.role || 'USER']
|
||||
)
|
||||
|
||||
return mapUser(result.rows[0])
|
||||
}
|
||||
|
||||
export async function updateUser(context: Context, id: string, input: any) {
|
||||
const db = context.db
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
let paramCount = 1
|
||||
|
||||
if (input.name !== undefined) {
|
||||
updates.push(`name = $${paramCount}`)
|
||||
params.push(input.name)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (input.role !== undefined) {
|
||||
updates.push(`role = $${paramCount}`)
|
||||
params.push(input.role)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return getUser(context, id)
|
||||
}
|
||||
|
||||
params.push(id)
|
||||
const result = await db.query(
|
||||
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramCount}
|
||||
RETURNING id, email, name, role, created_at, updated_at`,
|
||||
params
|
||||
)
|
||||
|
||||
return mapUser(result.rows[0])
|
||||
}
|
||||
|
||||
export async function deleteUser(context: Context, id: string) {
|
||||
const db = context.db
|
||||
await db.query('DELETE FROM users WHERE id = $1', [id])
|
||||
return true
|
||||
}
|
||||
|
||||
function mapUser(row: any) {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
17
api/src/types/context.ts
Normal file
17
api/src/types/context.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { FastifyRequest } from 'fastify'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: 'ADMIN' | 'USER' | 'VIEWER'
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
request: FastifyRequest
|
||||
user?: User
|
||||
db: any // Database connection - will be typed properly later
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user