Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
b4a17c2d35

+ 76 - 0
src/app/api/desktop/v1/account/password/route.ts

@@ -0,0 +1,76 @@
+import { NextResponse } from "next/server";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { auth } from "@/lib/auth";
+
+export async function PUT(request: Request) {
+  return withDesktopAuth(request, async ({ userId }) => {
+    const body = await request.json();
+    const { currentPassword, newPassword } = body;
+
+    if (!currentPassword || typeof currentPassword !== "string") {
+      return NextResponse.json(
+        { error: "currentPassword is required" },
+        { status: 400 },
+      );
+    }
+
+    if (!newPassword || typeof newPassword !== "string") {
+      return NextResponse.json(
+        { error: "newPassword is required" },
+        { status: 400 },
+      );
+    }
+
+    if (newPassword.length < 8) {
+      return NextResponse.json(
+        { error: "New password must be at least 8 characters" },
+        { status: 400 },
+      );
+    }
+
+    try {
+      // Use better-auth's server-side API to change the password.
+      // Pass the original Authorization header so better-auth can resolve the session.
+      const headers = new Headers();
+      const authHeader = request.headers.get("Authorization");
+      if (authHeader) {
+        headers.set("Authorization", authHeader);
+      }
+
+      const result = await auth.api.changePassword({
+        body: { currentPassword, newPassword },
+        headers,
+      });
+
+      if (!result) {
+        return NextResponse.json(
+          { error: "Password change failed" },
+          { status: 400 },
+        );
+      }
+
+      return NextResponse.json({ success: true });
+    } catch (err) {
+      const message =
+        err instanceof Error ? err.message : "Password change failed";
+
+      // better-auth throws when the current password is wrong
+      if (
+        message.toLowerCase().includes("invalid") ||
+        message.toLowerCase().includes("incorrect") ||
+        message.toLowerCase().includes("wrong")
+      ) {
+        return NextResponse.json(
+          { error: "Current password is incorrect" },
+          { status: 400 },
+        );
+      }
+
+      console.error("[desktop-api] password change error:", err);
+      return NextResponse.json(
+        { error: "Password change failed" },
+        { status: 500 },
+      );
+    }
+  });
+}

+ 68 - 0
src/app/api/desktop/v1/account/route.ts

@@ -0,0 +1,68 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+
+export async function PUT(request: Request) {
+  return withDesktopAuth(request, async ({ userId }) => {
+    const body = await request.json();
+    const { name, email } = body;
+
+    // Build update data from provided fields
+    const data: { name?: string; email?: string } = {};
+
+    if (name !== undefined) {
+      if (typeof name !== "string" || name.trim().length === 0) {
+        return NextResponse.json(
+          { error: "Name must be a non-empty string" },
+          { status: 400 },
+        );
+      }
+      data.name = name.trim();
+    }
+
+    if (email !== undefined) {
+      if (typeof email !== "string" || email.trim().length === 0) {
+        return NextResponse.json(
+          { error: "Email must be a non-empty string" },
+          { status: 400 },
+        );
+      }
+      // Basic email format check
+      if (!email.includes("@")) {
+        return NextResponse.json(
+          { error: "Invalid email format" },
+          { status: 400 },
+        );
+      }
+
+      // Check if email is already in use by another user
+      const existing = await db.user.findUnique({
+        where: { email: email.trim().toLowerCase() },
+        select: { id: true },
+      });
+      if (existing && existing.id !== userId) {
+        return NextResponse.json(
+          { error: "Email is already in use" },
+          { status: 409 },
+        );
+      }
+
+      data.email = email.trim().toLowerCase();
+    }
+
+    if (Object.keys(data).length === 0) {
+      return NextResponse.json(
+        { error: "No fields to update. Provide name and/or email." },
+        { status: 400 },
+      );
+    }
+
+    const user = await db.user.update({
+      where: { id: userId },
+      data,
+      select: { id: true, name: true, email: true },
+    });
+
+    return NextResponse.json({ user });
+  });
+}

+ 56 - 0
src/app/api/desktop/v1/custom-fields/[id]/route.ts

@@ -0,0 +1,56 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+
+export async function PUT(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(request, async ({ organizationId }) => {
+    const { id } = await params;
+    const body = await request.json();
+    const { label, fieldType, options, required, sortOrder, isActive } = body;
+
+    const existing = await db.customFieldDefinition.findFirst({
+      where: { id, organizationId },
+    });
+    if (!existing) {
+      return NextResponse.json({ error: "Field not found" }, { status: 404 });
+    }
+
+    const field = await db.customFieldDefinition.update({
+      where: { id },
+      data: {
+        ...(label !== undefined && { label }),
+        ...(fieldType !== undefined && { fieldType }),
+        ...(options !== undefined && { options }),
+        ...(required !== undefined && { required }),
+        ...(sortOrder !== undefined && { sortOrder }),
+        ...(isActive !== undefined && { isActive }),
+      },
+    });
+
+    return NextResponse.json({ field });
+  });
+}
+
+export async function DELETE(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(request, async ({ organizationId }) => {
+    const { id } = await params;
+
+    const existing = await db.customFieldDefinition.findFirst({
+      where: { id, organizationId },
+    });
+    if (!existing) {
+      return NextResponse.json({ error: "Field not found" }, { status: 404 });
+    }
+
+    await db.customFieldValue.deleteMany({ where: { fieldId: id } });
+    await db.customFieldDefinition.delete({ where: { id } });
+
+    return NextResponse.json({ success: true });
+  });
+}

+ 48 - 0
src/app/api/desktop/v1/custom-fields/route.ts

@@ -0,0 +1,48 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+
+export async function GET(request: Request) {
+  return withDesktopAuth(request, async ({ organizationId }) => {
+    const { searchParams } = new URL(request.url);
+    const entityType = searchParams.get("entityType");
+
+    const where: Record<string, unknown> = { organizationId };
+    if (entityType) where.entityType = entityType;
+
+    const fields = await db.customFieldDefinition.findMany({
+      where,
+      orderBy: { sortOrder: "asc" },
+    });
+
+    return NextResponse.json({ fields });
+  });
+}
+
+export async function POST(request: Request) {
+  return withDesktopAuth(request, async ({ organizationId, userId }) => {
+    const body = await request.json();
+    const { name, label, fieldType, options, required, entityType, sortOrder, isActive } = body;
+
+    if (!name || !label || !entityType) {
+      return NextResponse.json({ error: "name, label, and entityType are required" }, { status: 400 });
+    }
+
+    const field = await db.customFieldDefinition.create({
+      data: {
+        name,
+        label,
+        fieldType: fieldType || "text",
+        options: options || null,
+        required: required ?? false,
+        entityType,
+        sortOrder: sortOrder ?? 0,
+        isActive: isActive ?? true,
+        userId,
+        organizationId,
+      },
+    });
+
+    return NextResponse.json({ field });
+  });
+}

+ 39 - 0
src/app/api/desktop/v1/email/test/route.ts

@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
+
+export async function POST(request: Request) {
+  return withDesktopAuth(request, async ({ userId, organizationId }) => {
+    const user = await db.user.findUnique({
+      where: { id: userId },
+      select: { email: true },
+    });
+
+    if (!user?.email) {
+      return NextResponse.json({ error: "Could not find your email address" }, { status: 400 });
+    }
+
+    const from = await getOrgFromAddress(organizationId);
+
+    await sendOrgMail(organizationId, {
+      from,
+      to: user.email,
+      subject: "Email Test - Torqvoice",
+      html: `
+        <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
+          <h2>Email Configuration Test</h2>
+          <p>This is a test email from your organization's email settings.</p>
+          <p>If you're reading this, your email provider is configured correctly.</p>
+          <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
+          <p style="color: #6b7280; font-size: 12px;">
+            Sent to: ${user.email}<br/>
+            Time: ${new Date().toISOString()}
+          </p>
+        </div>
+      `,
+    });
+
+    return NextResponse.json({ sentTo: user.email });
+  });
+}

+ 11 - 1
src/app/api/desktop/v1/health/route.ts

@@ -1,5 +1,15 @@
 import { NextResponse } from "next/server";
+import { readFileSync } from "fs";
+import { join } from "path";
+
+let appVersion: string | undefined;
+try {
+  const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf-8"));
+  appVersion = pkg.version;
+} catch {
+  // ignore
+}
 
 export async function GET() {
-  return NextResponse.json({ status: "ok" });
+  return NextResponse.json({ status: "ok", version: appVersion ?? "unknown" });
 }

+ 32 - 0
src/app/api/desktop/v1/organization/route.ts

@@ -0,0 +1,32 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+
+export async function PUT(request: Request) {
+  return withDesktopAuth(request, async ({ organizationId, isAdmin }) => {
+    if (!isAdmin) {
+      return NextResponse.json(
+        { error: "Admin access required" },
+        { status: 403 },
+      );
+    }
+
+    const body = await request.json();
+    const { name } = body;
+
+    if (!name || typeof name !== "string" || name.trim().length === 0) {
+      return NextResponse.json(
+        { error: "Name is required and must be a non-empty string" },
+        { status: 400 },
+      );
+    }
+
+    const organization = await db.organization.update({
+      where: { id: organizationId },
+      data: { name: name.trim() },
+      select: { id: true, name: true },
+    });
+
+    return NextResponse.json({ organization });
+  });
+}

+ 89 - 0
src/app/api/desktop/v1/portal/route.ts

@@ -0,0 +1,89 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+
+export async function GET(request: Request) {
+  return withDesktopAuth(request, async ({ organizationId }) => {
+    const org = await db.organization.findUnique({
+      where: { id: organizationId },
+      select: { portalSlug: true },
+    });
+
+    const portalEnabled = await db.appSetting.findUnique({
+      where: { organizationId_key: { organizationId, key: "portal.enabled" } },
+    });
+
+    return NextResponse.json({
+      portalSlug: org?.portalSlug || null,
+      portalEnabled: portalEnabled?.value === "true",
+    });
+  });
+}
+
+const SLUG_REGEX = /^[a-z0-9][a-z0-9_-]{1,46}[a-z0-9]$/;
+const RESERVED_SLUGS = ["auth", "api", "admin", "login", "verify", "dashboard", "settings", "portal"];
+
+export async function PUT(request: Request) {
+  return withDesktopAuth(request, async ({ organizationId }) => {
+    const body = await request.json();
+    const { slug, enabled } = body;
+
+    // Update portal enabled setting if provided
+    if (enabled !== undefined) {
+      await db.appSetting.upsert({
+        where: { organizationId_key: { organizationId, key: "portal.enabled" } },
+        update: { value: String(enabled) },
+        create: {
+          organizationId,
+          key: "portal.enabled",
+          value: String(enabled),
+          userId: "", // system setting
+        },
+      });
+    }
+
+    // Update slug if provided
+    if (slug !== undefined) {
+      if (!slug || slug.trim() === "") {
+        await db.organization.update({
+          where: { id: organizationId },
+          data: { portalSlug: null },
+        });
+      } else {
+        const normalized = slug.trim().toLowerCase();
+
+        if (!SLUG_REGEX.test(normalized)) {
+          return NextResponse.json(
+            { error: "Slug must be 3-48 characters, lowercase alphanumeric, hyphens, or underscores." },
+            { status: 400 },
+          );
+        }
+
+        if (RESERVED_SLUGS.includes(normalized)) {
+          return NextResponse.json({ error: "This slug is reserved." }, { status: 400 });
+        }
+
+        const existing = await db.organization.findUnique({
+          where: { portalSlug: normalized },
+          select: { id: true },
+        });
+
+        if (existing && existing.id !== organizationId) {
+          return NextResponse.json({ error: "This slug is already taken." }, { status: 400 });
+        }
+
+        await db.organization.update({
+          where: { id: organizationId },
+          data: { portalSlug: normalized },
+        });
+      }
+    }
+
+    const org = await db.organization.findUnique({
+      where: { id: organizationId },
+      select: { portalSlug: true },
+    });
+
+    return NextResponse.json({ portalSlug: org?.portalSlug || null });
+  });
+}

+ 78 - 0
src/app/api/desktop/v1/settings/route.ts

@@ -0,0 +1,78 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export async function PUT(request: Request) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId, userId }) => {
+      const body = await request.json();
+      const { settings } = body;
+
+      if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
+        return NextResponse.json(
+          { error: "Invalid body: expected { settings: Record<string, string> }" },
+          { status: 400 },
+        );
+      }
+
+      const entries = Object.entries(settings) as [string, string][];
+
+      if (entries.length === 0) {
+        return NextResponse.json(
+          { error: "No settings provided" },
+          { status: 400 },
+        );
+      }
+
+      // Validate all values are strings
+      for (const [key, value] of entries) {
+        if (typeof key !== "string" || typeof value !== "string") {
+          return NextResponse.json(
+            { error: `Invalid setting: key and value must be strings` },
+            { status: 400 },
+          );
+        }
+      }
+
+      // Upsert each setting
+      await Promise.all(
+        entries.map(([key, value]) =>
+          db.appSetting.upsert({
+            where: {
+              organizationId_key: { organizationId, key },
+            },
+            create: {
+              key,
+              value,
+              userId,
+              organizationId,
+            },
+            update: {
+              value,
+            },
+          }),
+        ),
+      );
+
+      // Return updated settings for this org
+      const allSettings = await db.appSetting.findMany({
+        where: { organizationId },
+        select: { key: true, value: true },
+      });
+
+      const result: Record<string, string> = {};
+      for (const s of allSettings) {
+        result[s.key] = s.value;
+      }
+
+      return NextResponse.json({ settings: result });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}

+ 21 - 0
src/app/api/desktop/v1/sms/test/route.ts

@@ -0,0 +1,21 @@
+import { NextResponse } from "next/server";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { sendOrgSms } from "@/lib/sms";
+
+export async function POST(request: Request) {
+  return withDesktopAuth(request, async ({ organizationId }) => {
+    const body = await request.json();
+    const { phone } = body;
+
+    if (!phone?.trim()) {
+      return NextResponse.json({ error: "Phone number is required" }, { status: 400 });
+    }
+
+    await sendOrgSms(organizationId, {
+      to: phone.trim(),
+      body: "SMS test from Torqvoice — your SMS provider is configured correctly.",
+    });
+
+    return NextResponse.json({ sentTo: phone.trim() });
+  });
+}

+ 116 - 0
src/app/api/desktop/v1/team/invite/route.ts

@@ -0,0 +1,116 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import crypto from "crypto";
+
+export async function POST(request: Request) {
+  return withDesktopAuth(request, async ({ organizationId, userId, isAdmin }) => {
+    if (!isAdmin) {
+      return NextResponse.json(
+        { error: "Admin access required" },
+        { status: 403 },
+      );
+    }
+
+    const body = await request.json();
+    const { email, role, roleId } = body;
+
+    if (!email || typeof email !== "string" || !email.includes("@")) {
+      return NextResponse.json(
+        { error: "A valid email is required" },
+        { status: 400 },
+      );
+    }
+
+    if (!role || typeof role !== "string") {
+      return NextResponse.json(
+        { error: "role is required" },
+        { status: 400 },
+      );
+    }
+
+    const normalizedEmail = email.trim().toLowerCase();
+
+    // Validate roleId if provided
+    if (roleId) {
+      const customRole = await db.role.findFirst({
+        where: { id: roleId, organizationId },
+      });
+      if (!customRole) {
+        return NextResponse.json(
+          { error: "Custom role not found" },
+          { status: 404 },
+        );
+      }
+    }
+
+    // Check if user already a member
+    const existingMember = await db.organizationMember.findFirst({
+      where: {
+        organizationId,
+        user: { email: normalizedEmail },
+      },
+    });
+
+    if (existingMember) {
+      return NextResponse.json(
+        { error: "User is already a member of this organization" },
+        { status: 409 },
+      );
+    }
+
+    // Check if user exists
+    const existingUser = await db.user.findUnique({
+      where: { email: normalizedEmail },
+      select: { id: true },
+    });
+
+    if (existingUser) {
+      // User exists, add them directly as a member
+      await db.organizationMember.create({
+        data: {
+          userId: existingUser.id,
+          organizationId,
+          role,
+          roleId: roleId || null,
+        },
+      });
+
+      return NextResponse.json({
+        success: true,
+        invited: false,
+        userNotFound: false,
+      });
+    }
+
+    // User does not exist, create an invitation
+    // Cancel any existing pending invitation for this email + org
+    await db.teamInvitation.updateMany({
+      where: {
+        email: normalizedEmail,
+        organizationId,
+        status: "pending",
+      },
+      data: { status: "cancelled" },
+    });
+
+    await db.teamInvitation.create({
+      data: {
+        email: normalizedEmail,
+        role,
+        roleId: roleId || null,
+        organizationId,
+        invitedById: userId,
+        token: crypto.randomUUID(),
+        status: "pending",
+        expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
+      },
+    });
+
+    return NextResponse.json({
+      success: true,
+      invited: true,
+      userNotFound: true,
+    });
+  });
+}

+ 129 - 0
src/app/api/desktop/v1/team/members/[id]/route.ts

@@ -0,0 +1,129 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+
+export async function PUT(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+
+  return withDesktopAuth(request, async ({ organizationId, isAdmin }) => {
+    if (!isAdmin) {
+      return NextResponse.json(
+        { error: "Admin access required" },
+        { status: 403 },
+      );
+    }
+
+    const body = await request.json();
+    const { role, roleId } = body;
+
+    if (!role || typeof role !== "string") {
+      return NextResponse.json(
+        { error: "role is required" },
+        { status: 400 },
+      );
+    }
+
+    // Find the member
+    const member = await db.organizationMember.findFirst({
+      where: { id, organizationId },
+    });
+
+    if (!member) {
+      return NextResponse.json(
+        { error: "Member not found" },
+        { status: 404 },
+      );
+    }
+
+    // Cannot change owner's role
+    if (member.role === "owner") {
+      return NextResponse.json(
+        { error: "Cannot change the owner's role" },
+        { status: 403 },
+      );
+    }
+
+    // Validate roleId exists if provided
+    if (roleId) {
+      const customRole = await db.role.findFirst({
+        where: { id: roleId, organizationId },
+      });
+      if (!customRole) {
+        return NextResponse.json(
+          { error: "Custom role not found" },
+          { status: 404 },
+        );
+      }
+    }
+
+    const updated = await db.organizationMember.update({
+      where: { id },
+      data: {
+        role,
+        roleId: roleId || null,
+      },
+      include: {
+        user: { select: { id: true, name: true, email: true } },
+        customRole: { select: { id: true, name: true } },
+      },
+    });
+
+    return NextResponse.json({
+      member: {
+        id: updated.id,
+        userId: updated.user.id,
+        name: updated.user.name,
+        email: updated.user.email,
+        role: updated.role,
+        customRole: updated.customRole
+          ? { id: updated.customRole.id, name: updated.customRole.name }
+          : undefined,
+      },
+    });
+  });
+}
+
+export async function DELETE(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+
+  return withDesktopAuth(request, async ({ organizationId, isAdmin }) => {
+    if (!isAdmin) {
+      return NextResponse.json(
+        { error: "Admin access required" },
+        { status: 403 },
+      );
+    }
+
+    // Find the member
+    const member = await db.organizationMember.findFirst({
+      where: { id, organizationId },
+    });
+
+    if (!member) {
+      return NextResponse.json(
+        { error: "Member not found" },
+        { status: 404 },
+      );
+    }
+
+    // Cannot remove the owner
+    if (member.role === "owner") {
+      return NextResponse.json(
+        { error: "Cannot remove the organization owner" },
+        { status: 403 },
+      );
+    }
+
+    await db.organizationMember.delete({
+      where: { id },
+    });
+
+    return NextResponse.json({ success: true });
+  });
+}

+ 57 - 0
src/app/api/desktop/v1/team/route.ts

@@ -0,0 +1,57 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+
+export async function GET(request: Request) {
+  return withDesktopAuth(request, async ({ organizationId }) => {
+    const [members, roles, invitations] = await Promise.all([
+      db.organizationMember.findMany({
+        where: { organizationId },
+        include: {
+          user: { select: { id: true, name: true, email: true } },
+          customRole: { select: { id: true, name: true } },
+        },
+      }),
+      db.role.findMany({
+        where: { organizationId },
+        include: {
+          _count: { select: { members: true } },
+        },
+      }),
+      db.teamInvitation.findMany({
+        where: { organizationId, status: "pending" },
+        select: {
+          id: true,
+          email: true,
+          role: true,
+          expiresAt: true,
+        },
+      }),
+    ]);
+
+    return NextResponse.json({
+      members: members.map((m) => ({
+        id: m.id,
+        userId: m.user.id,
+        name: m.user.name,
+        email: m.user.email,
+        role: m.role,
+        customRole: m.customRole
+          ? { id: m.customRole.id, name: m.customRole.name }
+          : undefined,
+      })),
+      roles: roles.map((r) => ({
+        id: r.id,
+        name: r.name,
+        isAdmin: r.isAdmin,
+        memberCount: r._count.members,
+      })),
+      invitations: invitations.map((inv) => ({
+        id: inv.id,
+        email: inv.email,
+        role: inv.role,
+        expiresAt: inv.expiresAt.toISOString(),
+      })),
+    });
+  });
+}