diff --git a/src/index.ts b/src/index.ts index 73cf634..265e5ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,11 @@ import notificationsRoutes from "./routes/notifications"; import staffTimeRoutes from "./routes/staff/time"; import staffTimeConnectRoutes from "./routes/staff/timeconnects"; +//Resources +import customerRoutes from "./routes/resources/customers"; +import vendorRoutes from "./routes/resources/vendors"; + + //M2M import authM2m from "./plugins/auth.m2m"; import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email"; @@ -114,6 +119,10 @@ async function main() { await subApp.register(staffTimeRoutes); await subApp.register(staffTimeConnectRoutes); + + await subApp.register(customerRoutes); + await subApp.register(vendorRoutes); + },{prefix: "/api"}) app.ready(async () => { diff --git a/src/routes/auth/me.ts b/src/routes/auth/me.ts index e99de99..fffb793 100644 --- a/src/routes/auth/me.ts +++ b/src/routes/auth/me.ts @@ -1,79 +1,140 @@ -import { FastifyInstance } from "fastify"; +import { FastifyInstance } from "fastify" +import { + authUsers, + authTenantUsers, + tenants, + authProfiles, + authUserRoles, + authRoles, + authRolePermissions, +} from "../../../db/schema" +import { eq, and, or, isNull } from "drizzle-orm" export default async function meRoutes(server: FastifyInstance) { server.get("/me", async (req, reply) => { - const authUser = req.user // kommt aus JWT (user_id + tenant_id) + try { + const authUser = req.user - if (!authUser) { - return reply.code(401).send({ error: "Unauthorized" }) - } + if (!authUser) { + return reply.code(401).send({ error: "Unauthorized" }) + } - const user_id = req.user.user_id - const tenant_id = req.user.tenant_id + const userId = authUser.user_id + const activeTenantId = authUser.tenant_id - // 1. User laden - const { data: user, error: userError } = await server.supabase - .from("auth_users") - .select("id, email, created_at, must_change_password") - .eq("id", authUser.user_id) - .single() + // ---------------------------------------------------- + // 1) USER LADEN + // ---------------------------------------------------- + const userResult = await server.db + .select({ + id: authUsers.id, + email: authUsers.email, + created_at: authUsers.created_at, + must_change_password: authUsers.must_change_password, + }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .limit(1) - if (userError || !user) { - return reply.code(401).send({ error: "User not found" }) - } + const user = userResult[0] - // 2. Tenants laden (alle Tenants des Users) - const { data: tenantLinks, error: tenantLinksError } = await server.supabase - .from("auth_users") - .select(`*, tenants!auth_tenant_users ( id, name,short, locked, extraModules, businessInfo, numberRanges, dokuboxkey, standardEmailForInvoices, standardPaymentDays )`) - .eq("id", authUser.user_id) - .single(); + if (!user) { + return reply.code(401).send({ error: "User not found" }) + } - if (tenantLinksError) { + // ---------------------------------------------------- + // 2) TENANTS LADEN + // ---------------------------------------------------- + const tenantRows = await server.db + .select({ + id: tenants.id, + name: tenants.name, + short: tenants.short, + locked: tenants.locked, + extraModules: tenants.extraModules, + businessInfo: tenants.businessInfo, + numberRanges: tenants.numberRanges, + dokuboxkey: tenants.dokuboxkey, + standardEmailForInvoices: tenants.standardEmailForInvoices, + standardPaymentDays: tenants.standardPaymentDays, + }) + .from(authTenantUsers) + .innerJoin(tenants, eq(authTenantUsers.tenant_id, tenants.id)) + .where(eq(authTenantUsers.user_id, userId)) - console.log(tenantLinksError) + const tenantList = tenantRows ?? [] - return reply.code(401).send({ error: "Tenant Error" }) - } + // ---------------------------------------------------- + // 3) ACTIVE TENANT + // ---------------------------------------------------- + const activeTenant = activeTenantId - const tenants = tenantLinks?.tenants + // ---------------------------------------------------- + // 4) PROFIL LADEN + // ---------------------------------------------------- + let profile = null + if (activeTenantId) { + const profileResult = await server.db + .select() + .from(authProfiles) + .where( + and( + eq(authProfiles.user_id, userId), + eq(authProfiles.tenant_id, activeTenantId) + ) + ) + .limit(1) - // 3. Aktiven Tenant bestimmen - const activeTenant = authUser.tenant_id /*|| tenants[0].id*/ + profile = profileResult?.[0] ?? null + } - // 4. Profil für den aktiven Tenant laden - let profile = null - if (activeTenant) { - const { data: profileData } = await server.supabase - .from("auth_profiles") - .select("*") - .eq("user_id", user.id) - .eq("tenant_id", activeTenant) - .single() + // ---------------------------------------------------- + // 5) PERMISSIONS — RPC ERSETZT + // ---------------------------------------------------- + const permissionRows = + (await server.db + .select({ + permission: authRolePermissions.permission, + }) + .from(authUserRoles) + .innerJoin( + authRoles, + and( + eq(authRoles.id, authUserRoles.role_id), + or( + isNull(authRoles.tenant_id), // globale Rolle + eq(authRoles.tenant_id, activeTenantId) // tenant-spezifische Rolle + ) + ) + ) + .innerJoin( + authRolePermissions, + eq(authRolePermissions.role_id, authRoles.id) + ) + .where( + and( + eq(authUserRoles.user_id, userId), + eq(authUserRoles.tenant_id, activeTenantId) + ) + )) ?? [] - profile = profileData - } + const permissions = Array.from( + new Set(permissionRows.map((p) => p.permission)) + ) - // 5. Permissions laden (über Funktion) - const { data: permissionsData, error: permissionsError } = await server.supabase - .rpc("auth_get_user_permissions", { - uid: user.id, - tid: activeTenant || null - }) - - if(permissionsError) { - console.log(permissionsError) - } - - const permissions = permissionsData.map(i => i.permission) || [] - - // 6. Response zurückgeben - return { - user, - tenants, - activeTenant, - profile, - permissions + // ---------------------------------------------------- + // RESPONSE + // ---------------------------------------------------- + return { + user, + tenants: tenantList, + activeTenant, + profile, + permissions, + } + } catch (err: any) { + console.error("ERROR in /me route:", err) + return reply.code(500).send({ error: "Internal server error" }) } }) -} \ No newline at end of file +} diff --git a/src/routes/resources/_template.ts b/src/routes/resources/_template.ts new file mode 100644 index 0000000..03386e5 --- /dev/null +++ b/src/routes/resources/_template.ts @@ -0,0 +1,155 @@ +import { FastifyInstance } from "fastify" +import { asc, desc, eq, ilike, and } from "drizzle-orm" + +// Beispiel-Import — wird in der echten Datei ersetzt +import { exampleTable } from "../../../db/schema/exampleTable" + +export default async function exampleRoutes(server: FastifyInstance) { + + // ------------------------------------------- + // LIST (ohne Pagination) + // ------------------------------------------- + server.get("/resource/example", async (req, reply) => { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const { search, sort, asc: ascQuery } = req.query as { + search?: string + sort?: string + asc?: string + } + + let query = server.db + .select() + .from(exampleTable) + .where(eq(exampleTable.tenant, tenantId)) + + // 🔍 OPTIONAL: einfache Suche (LIKE) + if (search) { + query = server.db + .select() + .from(exampleTable) + .where( + and( + eq(exampleTable.tenant, tenantId), + ilike(exampleTable.name as any, `%${search}%`) + ) + ) + } + + // 🔄 Sortierung + if (sort) { + query = query.orderBy( + ascQuery === "true" + ? asc((exampleTable as any)[sort]) + : desc((exampleTable as any)[sort]) + ) + } + + const results = await query + return results + }) + + // ------------------------------------------- + // PAGINATED LIST + // ------------------------------------------- + server.get("/resource/example/paginated", async (req, reply) => { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const { + offset = "0", + limit = "25", + search, + sort, + asc: ascQuery, + } = req.query as { + offset?: string + limit?: string + search?: string + sort?: string + asc?: string + } + + const offsetNum = parseInt(offset) + const limitNum = parseInt(limit) + + // --- Basis WHERE + let whereClause: any = eq(exampleTable.tenant, tenantId) + + // --- Suche + if (search) { + whereClause = and( + eq(exampleTable.tenant, tenantId), + ilike(exampleTable.name as any, `%${search}%`) + ) + } + + // ------------------------- + // 1. COUNT Query (total rows) + // ------------------------- + const totalRowsResult = await server.db + .select({ count: server.db.fn.count(exampleTable.id) }) + .from(exampleTable) + .where(whereClause) + + const total = Number(totalRowsResult[0].count) + + // ------------------------- + // 2. DATA Query + // ------------------------- + let dataQuery = server.db + .select() + .from(exampleTable) + .where(whereClause) + .offset(offsetNum) + .limit(limitNum) + + if (sort) { + dataQuery = dataQuery.orderBy( + ascQuery === "true" + ? asc((exampleTable as any)[sort]) + : desc((exampleTable as any)[sort]) + ) + } + + const rows = await dataQuery + + return { + data: rows, + pagination: { + total, + offset: offsetNum, + limit: limitNum, + totalPages: Math.ceil(total / limitNum), + }, + } + }) + + // ------------------------------------------- + // DETAIL ROUTE + // ------------------------------------------- + server.get("/resource/example/:id", async (req, reply) => { + const { id } = req.params as { id: string } + const tenantId = req.user?.tenant_id + + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const result = await server.db + .select() + .from(exampleTable) + .where( + and( + eq(exampleTable.id, id), + eq(exampleTable.tenant, tenantId) + ) + ) + .limit(1) + + if (!result.length) { + return reply.code(404).send({ error: "Not found" }) + } + + return result[0] + }) +} diff --git a/src/routes/resources/customers.ts b/src/routes/resources/customers.ts new file mode 100644 index 0000000..5888b55 --- /dev/null +++ b/src/routes/resources/customers.ts @@ -0,0 +1,328 @@ +import { FastifyInstance } from "fastify" +import { + eq, + ilike, + asc, + desc, + and, count, inArray, +} from "drizzle-orm" + +import { + customers, + projects, + plants, + contracts, + contacts, + createddocuments, + statementallocations, + files, + events, +} from "../../../db/schema" // dein zentraler index.ts Export + +export default async function customerRoutes(server: FastifyInstance) { + + + // ------------------------------------------------------------- + // LIST + // ------------------------------------------------------------- + server.get("/resource/customers", async (req, reply) => { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const { search, sort, asc: ascQuery } = req.query as { + search?: string + sort?: string + asc?: string + } + + // Basisquery + let baseQuery = server.db + .select() + .from(customers) + .where(eq(customers.tenant, tenantId)) + + // Suche + if (search) { + baseQuery = server.db + .select() + .from(customers) + .where( + and( + eq(customers.tenant, tenantId), + ilike(customers.name, `%${search}%`) + ) + ) + } + + // Sortierung + if (sort) { + const field = (customers as any)[sort] + if (field) { + // @ts-ignore + baseQuery = baseQuery.orderBy( + ascQuery === "true" ? asc(field) : desc(field) + ) + } + } + + const customerList = await baseQuery + + return customerList + }) + + + + // ------------------------------------------------------------- + // PAGINATED + // ------------------------------------------------------------- + server.get("/resource/customers/paginated", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const queryConfig = req.queryConfig + const { + pagination, + sort, + filters, + paginationDisabled + } = queryConfig + + const { + select, + search, + searchColumns, + distinctColumns + } = req.query as { + select?: string + search?: string + searchColumns?: string + distinctColumns?: string + } + + // ---------------------------- + // WHERE CONDITIONS + // ---------------------------- + let whereCond: any = eq(customers.tenant, tenantId) + + if (filters) { + for (const [key, val] of Object.entries(filters)) { + const col = (customers as any)[key] + if (!col) continue + + if (Array.isArray(val)) { + whereCond = and(whereCond, inArray(col, val)) + } else if (val === true || val === false || val === null) { + whereCond = and(whereCond, eq(col, val)) + } else { + whereCond = and(whereCond, eq(col, val)) + } + } + } + + if (search && search.trim().length > 0) { + const searchTerm = `%${search.trim().toLowerCase()}%` + whereCond = and( + whereCond, + ilike(customers.name, searchTerm) + ) + } + + // ---------------------------- + // COUNT FIX (Drizzle-safe) + // ---------------------------- + const totalRes = await server.db + .select({ value: count(customers.id) }) + .from(customers) + .where(whereCond) + + const total = Number(totalRes[0]?.value ?? 0) + + // ---------------------------- + // DISTINCT VALUES (optional) + // ---------------------------- + const distinctValues: Record = {} + + if (distinctColumns) { + for (const colName of distinctColumns.split(",").map(v => v.trim())) { + const col = (customers as any)[colName] + if (!col) continue + + const rows = await server.db + .select({ v: col }) + .from(customers) + .where(eq(customers.tenant, tenantId)) + + const values = rows + .map(r => r.v) + .filter(v => v != null && v !== "") + + distinctValues[colName] = [...new Set(values)].sort() + } + } + + // ---------------------------- + // PAGINATION + // ---------------------------- + let offset = 0 + let limit = 999999 + + if (!paginationDisabled && pagination) { + offset = pagination.offset + limit = pagination.limit + } + + // ---------------------------- + // ORDER BY + // ---------------------------- + let orderField = null + let orderDirection: "asc" | "desc" = "asc" + + if (sort?.length > 0) { + const s = sort[0] + const col = (customers as any)[s.field] + if (col) { + orderField = col + orderDirection = s.direction === "asc" ? "asc" : "desc" + } + } + + // ---------------------------- + // QUERY DATA + // ---------------------------- + let dataQuery = server.db + .select() + .from(customers) + .where(whereCond) + .offset(offset) + .limit(limit) + + if (orderField) { + dataQuery = + orderDirection === "asc" + ? dataQuery.orderBy(asc(orderField)) + : dataQuery.orderBy(desc(orderField)) + } + + const data = await dataQuery + + // ---------------------------- + // BUILD RETURN CONFIG + // ---------------------------- + const totalPages = pagination?.limit + ? Math.ceil(total / pagination.limit) + : 1 + + const enrichedConfig = { + ...queryConfig, + total, + totalPages, + distinctValues, + search: search || null, + } + + return { + data, + queryConfig: enrichedConfig, + } + } + catch (e) { + console.log(e) + } + + }) + + + + // ------------------------------------------------------------- + // DETAIL (mit ALLEN JOINS) + // ------------------------------------------------------------- + server.get("/resource/customers/:id", async (req, reply) => { + const { id } = req.params as { id: string } + const tenantId = req.user?.tenant_id + + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + // --- 1) Customer selbst laden + const customerRecord = await server.db + .select() + .from(customers) + .where(and(eq(customers.id, Number(id)), eq(customers.tenant, tenantId))) + .limit(1) + + if (!customerRecord.length) { + return reply.code(404).send({ error: "Customer not found" }) + } + + const customer = customerRecord[0] + + + // --- 2) Relations: + const [ + customerProjects, + customerPlants, + customerContracts, + customerContacts, + customerDocuments, + customerFiles, + customerEvents, + ] = await Promise.all([ + // Projekte, die dem Kunden zugeordnet sind + server.db + .select() + .from(projects) + .where(eq(projects.customer, Number(id))), + + server.db + .select() + .from(plants) + .where(eq(plants.customer, Number(id))), + + server.db + .select() + .from(contracts) + .where(eq(contracts.customer, Number(id))), + + server.db + .select() + .from(contacts) + .where(eq(contacts.customer, Number(id))), + + // createddocuments + inner join statementallocations + server.db + .select({ + ...createddocuments, + allocations: statementallocations, + }) + .from(createddocuments) + .leftJoin( + statementallocations, + eq(statementallocations.cd_id, createddocuments.id) + ) + .where(eq(createddocuments.customer, Number(id))), + + server.db + .select() + .from(files) + .where(eq(files.customer, Number(id))), + + server.db + .select() + .from(events) + .where(eq(events.customer, Number(id))), + ]) + + return { + ...customer, + projects: customerProjects, + plants: customerPlants, + contracts: customerContracts, + contacts: customerContacts, + createddocuments: customerDocuments, + files: customerFiles, + events: customerEvents, + } + }) +} diff --git a/src/routes/resources/vendors.ts b/src/routes/resources/vendors.ts new file mode 100644 index 0000000..cbcac84 --- /dev/null +++ b/src/routes/resources/vendors.ts @@ -0,0 +1,284 @@ +import { FastifyInstance } from "fastify" +import { + eq, + ilike, + asc, + desc, + and, + count, + inArray +} from "drizzle-orm" + +import { + vendors, + contacts, + files, +} from "../../../db/schema" + +export default async function vendorRoutes(server: FastifyInstance) { + + // ------------------------------------------------------------- + // LIST + // ------------------------------------------------------------- + server.get("/resource/vendors", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const { search, sort, asc: ascQuery } = req.query as { + search?: string + sort?: string + asc?: string + } + + let baseQuery = server.db + .select() + .from(vendors) + .where(eq(vendors.tenant, tenantId)) + + // 🔎 Suche + if (search) { + baseQuery = server.db + .select() + .from(vendors) + .where( + and( + eq(vendors.tenant, tenantId), + ilike(vendors.name, `%${search}%`) + ) + ) + } + + // 🔽 Sortierung + if (sort) { + const field = (vendors as any)[sort] + if (field) { + // @ts-ignore + baseQuery = baseQuery.orderBy( + ascQuery === "true" ? asc(field) : desc(field) + ) + } + } + + return await baseQuery + } catch (err) { + console.error("ERROR /resource/vendors:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + + // ------------------------------------------------------------- + // PAGINATED + // ------------------------------------------------------------- + server.get("/resource/vendors/paginated", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const queryConfig = req.queryConfig + const { + pagination, + sort, + filters, + paginationDisabled + } = queryConfig + + const { + select, + search, + searchColumns, + distinctColumns + } = req.query as { + select?: string + search?: string + searchColumns?: string + distinctColumns?: string + } + + // ---------------------------- + // WHERE CONDITIONS + // ---------------------------- + let whereCond: any = eq(vendors.tenant, tenantId) + + if (filters) { + for (const [key, val] of Object.entries(filters)) { + const col = (vendors as any)[key] + if (!col) continue + + if (Array.isArray(val)) { + whereCond = and(whereCond, inArray(col, val)) + } else if (val === true || val === false || val === null) { + whereCond = and(whereCond, eq(col, val)) + } else { + whereCond = and(whereCond, eq(col, val)) + } + } + } + + if (search && search.trim().length > 0) { + whereCond = and( + whereCond, + ilike(vendors.name, `%${search.trim().toLowerCase()}%`) + ) + } + + // ---------------------------- + // COUNT + // ---------------------------- + const totalRes = await server.db + .select({ value: count(vendors.id) }) + .from(vendors) + .where(whereCond) + + const total = Number(totalRes[0]?.value ?? 0) + + // ---------------------------- + // DISTINCT FILTER VALUES + // ---------------------------- + const distinctValues: Record = {} + + if (distinctColumns) { + for (const colName of distinctColumns.split(",").map(v => v.trim())) { + const col = (vendors as any)[colName] + if (!col) continue + + const rows = await server.db + .select({ v: col }) + .from(vendors) + .where(eq(vendors.tenant, tenantId)) + + const values = rows + .map(r => r.v) + .filter(v => v != null && v !== "") + + distinctValues[colName] = [...new Set(values)].sort() + } + } + + // ---------------------------- + // PAGINATION + // ---------------------------- + let offset = 0 + let limit = 999999 + + if (!paginationDisabled && pagination) { + offset = pagination.offset + limit = pagination.limit + } + + // ---------------------------- + // ORDER BY + // ---------------------------- + let orderField = null + let orderDirection: "asc" | "desc" = "asc" + + if (sort?.length > 0) { + const s = sort[0] + const col = (vendors as any)[s.field] + if (col) { + orderField = col + orderDirection = s.direction === "asc" ? "asc" : "desc" + } + } + + // ---------------------------- + // QUERY DATA + // ---------------------------- + let dataQuery = server.db + .select() + .from(vendors) + .where(whereCond) + .offset(offset) + .limit(limit) + + if (orderField) { + dataQuery = + orderDirection === "asc" + ? dataQuery.orderBy(asc(orderField)) + : dataQuery.orderBy(desc(orderField)) + } + + const data = await dataQuery + + const totalPages = pagination?.limit + ? Math.ceil(total / pagination.limit) + : 1 + + const enrichedConfig = { + ...queryConfig, + total, + totalPages, + distinctValues, + search: search || null, + } + + return { data, queryConfig: enrichedConfig } + } catch (err) { + console.error("ERROR /resource/vendors/paginated:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + + + // ------------------------------------------------------------- + // DETAIL (mit JOINS) + // ------------------------------------------------------------- + server.get("/resource/vendors/:id", async (req, reply) => { + try { + const { id } = req.params as { id: string } + const tenantId = req.user?.tenant_id + + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const vendorId = Number(id) + + const vendorRecord = await server.db + .select() + .from(vendors) + .where( + and( + eq(vendors.id, vendorId), + eq(vendors.tenant, tenantId) + ) + ) + .limit(1) + + if (!vendorRecord.length) { + return reply.code(404).send({ error: "Vendor not found" }) + } + + const vendor = vendorRecord[0] + + // ---------------------------- + // RELATIONS + // ---------------------------- + const [ + vendorContacts, + vendorFiles, + ] = await Promise.all([ + server.db + .select() + .from(contacts) + .where(eq(contacts.vendor, vendorId)), + + server.db + .select() + .from(files) + .where(eq(files.vendor, vendorId)), + ]) + + return { + ...vendor, + contacts: vendorContacts, + files: vendorFiles, + } + } catch (err) { + console.error("ERROR /resource/vendors/:id:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) +}