Compare commits
8 Commits
dd99bbe2e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa02574f3 | ||
|
|
7fc16bb333 | ||
|
|
b076343c57 | ||
|
|
1a3f72205f | ||
|
|
bca8c0e782 | ||
|
|
0473291940 | ||
|
|
71bb2e951b | ||
|
|
e6e014ebb0 |
@@ -1,4 +1,4 @@
|
|||||||
name: Build Docker Image
|
name: Build and deploy Docker image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,13 +6,12 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: builder
|
||||||
|
env:
|
||||||
|
IMAGE: gitea.dsrptlab.com/optovia/teams/teams
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -20,12 +19,11 @@ jobs:
|
|||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
run: |
|
||||||
with:
|
docker build -t "$IMAGE:latest" -t "$IMAGE:${{ gitea.sha }}" .
|
||||||
context: .
|
docker push "$IMAGE:latest"
|
||||||
push: true
|
docker push "$IMAGE:${{ gitea.sha }}"
|
||||||
tags: gitea.dsrptlab.com/optovia/teams/teams:latest
|
|
||||||
|
|
||||||
- name: Deploy to Dokploy
|
- name: Deploy to Dokploy
|
||||||
run: curl -k -X POST "https://dokploy.dsrptlab.com/api/deploy/yDs4GYvNjEfC5rGVUHsOg"
|
run: curl -fsS -X POST "https://ind.dsrptlab.com/api/deploy/2Y2RjGRTm4HIkcO2UmyHL"
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "graphql-contracts"]
|
||||||
|
path = graphql-contracts
|
||||||
|
url = git@gitea.dsrptlab.com:optovia/teams-graphql-contracts.git
|
||||||
1
graphql-contracts
Submodule
1
graphql-contracts
Submodule
Submodule graphql-contracts added at c632d41e35
2664
package-lock.json
generated
2664
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -9,18 +9,16 @@
|
|||||||
"start": "prisma migrate deploy && node dist/index.js"
|
"start": "prisma migrate deploy && node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "^4.11.3",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@prisma/client": "^6.5.0",
|
"@prisma/client": "^6.5.0",
|
||||||
"@sentry/node": "^9.5.0",
|
"@sentry/node": "^9.5.0",
|
||||||
"cors": "^2.8.5",
|
"fastify": "^5.8.5",
|
||||||
"express": "^4.21.2",
|
|
||||||
"graphql": "^16.10.0",
|
"graphql": "^16.10.0",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
"jose": "^6.0.11"
|
"jose": "^6.0.11",
|
||||||
|
"mercurius": "^16.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/node": "^22.13.0",
|
"@types/node": "^22.13.0",
|
||||||
"prisma": "^6.5.0",
|
"prisma": "^6.5.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
|
|||||||
40
prisma/migrations/1_add_flutter_auth_sessions/migration.sql
Normal file
40
prisma/migrations/1_add_flutter_auth_sessions/migration.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "teams_app_loginchallenge" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"uuid" TEXT NOT NULL,
|
||||||
|
"phone" VARCHAR(50) NOT NULL,
|
||||||
|
"code" VARCHAR(20) NOT NULL,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"used_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "teams_app_loginchallenge_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "teams_app_authsession" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"token" VARCHAR(255) NOT NULL,
|
||||||
|
"user_id" INTEGER NOT NULL,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"revoked_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "teams_app_authsession_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "teams_app_loginchallenge_uuid_key" ON "teams_app_loginchallenge"("uuid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "teams_app_loginchallenge_phone_idx" ON "teams_app_loginchallenge"("phone");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "teams_app_authsession_token_key" ON "teams_app_authsession"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "teams_app_authsession_user_id_idx" ON "teams_app_authsession"("user_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "teams_app_authsession" ADD CONSTRAINT "teams_app_authsession_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth_user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -8,20 +8,20 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
uuid String @unique @default(uuid())
|
uuid String @unique @default(uuid())
|
||||||
name String @db.VarChar(200)
|
name String @db.VarChar(200)
|
||||||
teamType String @default("BUYER") @map("team_type") @db.VarChar(20)
|
teamType String @default("BUYER") @map("team_type") @db.VarChar(20)
|
||||||
logtoOrgId String? @map("logto_org_id") @db.VarChar(100)
|
logtoOrgId String? @map("logto_org_id") @db.VarChar(100)
|
||||||
ownerId Int? @map("owner_id")
|
ownerId Int? @map("owner_id")
|
||||||
owner User? @relation(fields: [ownerId], references: [id])
|
owner User? @relation(fields: [ownerId], references: [id])
|
||||||
selectedLocationType String? @map("selected_location_type") @db.VarChar(20)
|
selectedLocationType String? @map("selected_location_type") @db.VarChar(20)
|
||||||
selectedLocationUuid String? @map("selected_location_uuid") @db.VarChar(100)
|
selectedLocationUuid String? @map("selected_location_uuid") @db.VarChar(100)
|
||||||
selectedLocationName String? @map("selected_location_name") @db.VarChar(255)
|
selectedLocationName String? @map("selected_location_name") @db.VarChar(255)
|
||||||
selectedLocationLat Float? @map("selected_location_lat")
|
selectedLocationLat Float? @map("selected_location_lat")
|
||||||
selectedLocationLon Float? @map("selected_location_lon")
|
selectedLocationLon Float? @map("selected_location_lon")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
members TeamMember[]
|
members TeamMember[]
|
||||||
invitations TeamInvitation[]
|
invitations TeamInvitation[]
|
||||||
@@ -32,40 +32,67 @@ model Team {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
password String @default("") @db.VarChar(128)
|
password String @default("") @db.VarChar(128)
|
||||||
username String @unique @db.VarChar(150)
|
username String @unique @db.VarChar(150)
|
||||||
firstName String @default("") @map("first_name") @db.VarChar(150)
|
firstName String @default("") @map("first_name") @db.VarChar(150)
|
||||||
lastName String @default("") @map("last_name") @db.VarChar(150)
|
lastName String @default("") @map("last_name") @db.VarChar(150)
|
||||||
email String @default("") @db.VarChar(254)
|
email String @default("") @db.VarChar(254)
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
dateJoined DateTime @default(now()) @map("date_joined")
|
dateJoined DateTime @default(now()) @map("date_joined")
|
||||||
isSuperuser Boolean @default(false) @map("is_superuser")
|
isSuperuser Boolean @default(false) @map("is_superuser")
|
||||||
isStaff Boolean @default(false) @map("is_staff")
|
isStaff Boolean @default(false) @map("is_staff")
|
||||||
lastLogin DateTime? @map("last_login")
|
lastLogin DateTime? @map("last_login")
|
||||||
|
|
||||||
profile UserProfile?
|
profile UserProfile?
|
||||||
teams Team[]
|
sessions AuthSession[]
|
||||||
|
teams Team[]
|
||||||
memberships TeamMember[]
|
memberships TeamMember[]
|
||||||
|
|
||||||
@@map("auth_user")
|
@@map("auth_user")
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserProfile {
|
model UserProfile {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int @unique @map("user_id")
|
userId Int @unique @map("user_id")
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
logtoId String @unique @map("logto_id") @db.VarChar(255)
|
logtoId String @unique @map("logto_id") @db.VarChar(255)
|
||||||
avatarId String? @map("avatar_id") @db.VarChar(255)
|
avatarId String? @map("avatar_id") @db.VarChar(255)
|
||||||
phone String @default("") @db.VarChar(50)
|
phone String @default("") @db.VarChar(50)
|
||||||
activeTeamId Int? @map("active_team_id")
|
activeTeamId Int? @map("active_team_id")
|
||||||
activeTeam Team? @relation("ActiveTeam", fields: [activeTeamId], references: [id])
|
activeTeam Team? @relation("ActiveTeam", fields: [activeTeamId], references: [id])
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@map("teams_app_userprofile")
|
@@map("teams_app_userprofile")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model LoginChallenge {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
uuid String @unique @default(uuid())
|
||||||
|
phone String @db.VarChar(50)
|
||||||
|
code String @db.VarChar(20)
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
usedAt DateTime? @map("used_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([phone])
|
||||||
|
@@map("teams_app_loginchallenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuthSession {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
token String @unique @db.VarChar(255)
|
||||||
|
userId Int @map("user_id")
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
revokedAt DateTime? @map("revoked_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("teams_app_authsession")
|
||||||
|
}
|
||||||
|
|
||||||
model TeamMember {
|
model TeamMember {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
uuid String @unique @default(uuid())
|
uuid String @unique @default(uuid())
|
||||||
@@ -81,16 +108,16 @@ model TeamMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TeamInvitation {
|
model TeamInvitation {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
uuid String @unique @default(uuid())
|
uuid String @unique @default(uuid())
|
||||||
teamId Int @map("team_id")
|
teamId Int @map("team_id")
|
||||||
team Team @relation(fields: [teamId], references: [id])
|
team Team @relation(fields: [teamId], references: [id])
|
||||||
email String @db.VarChar(254)
|
email String @db.VarChar(254)
|
||||||
role String @default("MEMBER") @db.VarChar(20)
|
role String @default("MEMBER") @db.VarChar(20)
|
||||||
status String @default("PENDING") @db.VarChar(20)
|
status String @default("PENDING") @db.VarChar(20)
|
||||||
invitedBy String @default("") @map("invited_by") @db.VarChar(255)
|
invitedBy String @default("") @map("invited_by") @db.VarChar(255)
|
||||||
expiresAt DateTime? @map("expires_at")
|
expiresAt DateTime? @map("expires_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
tokens TeamInvitationToken[]
|
tokens TeamInvitationToken[]
|
||||||
|
|
||||||
@@ -99,14 +126,14 @@ model TeamInvitation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TeamInvitationToken {
|
model TeamInvitationToken {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
uuid String @unique @default(uuid())
|
uuid String @unique @default(uuid())
|
||||||
invitationId Int @map("invitation_id")
|
invitationId Int @map("invitation_id")
|
||||||
invitation TeamInvitation @relation(fields: [invitationId], references: [id])
|
invitation TeamInvitation @relation(fields: [invitationId], references: [id])
|
||||||
tokenHash String @unique @map("token_hash") @db.VarChar(255)
|
tokenHash String @unique @map("token_hash") @db.VarChar(255)
|
||||||
workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20)
|
workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20)
|
||||||
expiresAt DateTime? @map("expires_at")
|
expiresAt DateTime? @map("expires_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@map("teams_app_teaminvitationtoken")
|
@@map("teams_app_teaminvitationtoken")
|
||||||
}
|
}
|
||||||
|
|||||||
170
src/auth.ts
170
src/auth.ts
@@ -1,55 +1,151 @@
|
|||||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from "graphql";
|
||||||
import type { Request } from 'express'
|
import type { FastifyRequest } from "fastify";
|
||||||
|
import { prisma } from "./db.js";
|
||||||
|
|
||||||
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
|
const LOGTO_JWKS_URL =
|
||||||
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
|
process.env.LOGTO_JWKS_URL || "https://auth.optovia.ru/oidc/jwks";
|
||||||
const LOGTO_TEAMS_AUDIENCE = process.env.LOGTO_TEAMS_AUDIENCE || 'https://teams.optovia.ru'
|
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || "https://auth.optovia.ru/oidc";
|
||||||
|
const LOGTO_TEAMS_AUDIENCE =
|
||||||
|
process.env.LOGTO_TEAMS_AUDIENCE || "https://teams.optovia.ru";
|
||||||
|
|
||||||
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
|
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL));
|
||||||
|
|
||||||
export interface AuthContext {
|
export interface AuthContext {
|
||||||
userId?: string
|
userId?: string;
|
||||||
teamUuid?: string
|
teamUuid?: string;
|
||||||
scopes: string[]
|
sessionToken?: string;
|
||||||
isM2M?: boolean
|
scopes: string[];
|
||||||
|
isM2M?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBearerToken(req: Request): string {
|
export const SESSION_TOKEN_PREFIX = "optovia-session:";
|
||||||
const auth = req.headers.authorization || ''
|
|
||||||
if (!auth.startsWith('Bearer ')) throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
function getBearerToken(req: FastifyRequest): string {
|
||||||
const token = auth.slice(7)
|
const auth = req.headers.authorization || "";
|
||||||
if (!token || token === 'undefined') throw new GraphQLError('Empty Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
if (!auth.startsWith("Bearer "))
|
||||||
return token
|
throw new GraphQLError("Missing Bearer token", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
const token = auth.slice(7);
|
||||||
|
if (!token || token === "undefined")
|
||||||
|
throw new GraphQLError("Empty Bearer token", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalBearerToken(req: FastifyRequest): string | null {
|
||||||
|
const auth = req.headers.authorization || "";
|
||||||
|
if (!auth.startsWith("Bearer ")) return null;
|
||||||
|
const token = auth.slice(7);
|
||||||
|
if (!token || token === "undefined") return null;
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopesFromPayload(payload: JWTPayload): string[] {
|
function scopesFromPayload(payload: JWTPayload): string[] {
|
||||||
const scope = payload.scope
|
const scope = payload.scope;
|
||||||
if (!scope) return []
|
if (!scope) return [];
|
||||||
if (typeof scope === 'string') return scope.split(' ')
|
if (typeof scope === "string") return scope.split(" ");
|
||||||
return []
|
if (Array.isArray(scope)) return scope as string[];
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publicContext(): Promise<AuthContext> { return { scopes: [] } }
|
export async function publicContext(): Promise<AuthContext> {
|
||||||
|
return { scopes: [] };
|
||||||
export async function userContext(req: Request): Promise<AuthContext> {
|
|
||||||
const token = getBearerToken(req)
|
|
||||||
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
|
|
||||||
return { userId: payload.sub, scopes: [] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function teamContext(req: Request): Promise<AuthContext> {
|
function claimList(payload: JWTPayload, key: string): string[] {
|
||||||
const token = getBearerToken(req)
|
const value = (payload as Record<string, unknown>)[key];
|
||||||
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_TEAMS_AUDIENCE })
|
if (typeof value === "string") return value.split(" ");
|
||||||
const teamUuid = (payload as Record<string, unknown>).team_uuid as string | undefined
|
if (Array.isArray(value))
|
||||||
const scopes = scopesFromPayload(payload)
|
return value.filter((item): item is string => typeof item === "string");
|
||||||
if (!teamUuid || !scopes.includes('teams:member')) throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
|
return [];
|
||||||
return { userId: payload.sub, teamUuid, scopes }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function m2mContext(): Promise<AuthContext> { return { scopes: [], isM2M: true } }
|
function hasManagerClaim(payload: JWTPayload): boolean {
|
||||||
|
const scopes = scopesFromPayload(payload);
|
||||||
|
return (
|
||||||
|
scopes.includes("manager") ||
|
||||||
|
claimList(payload, "roles").includes("manager") ||
|
||||||
|
claimList(payload, "permissions").includes("manager")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userContext(req: FastifyRequest): Promise<AuthContext> {
|
||||||
|
const token = optionalBearerToken(req);
|
||||||
|
if (token === null) return { scopes: [] };
|
||||||
|
if (token.startsWith(SESSION_TOKEN_PREFIX)) {
|
||||||
|
const session = await prisma.authSession.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
session === null ||
|
||||||
|
session.revokedAt !== null ||
|
||||||
|
session.expiresAt <= new Date()
|
||||||
|
) {
|
||||||
|
throw new GraphQLError("Session expired", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: session.user.username,
|
||||||
|
sessionToken: token,
|
||||||
|
scopes: ["teams:user"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER });
|
||||||
|
return { userId: payload.sub, scopes: scopesFromPayload(payload) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function managerContext(
|
||||||
|
req: FastifyRequest,
|
||||||
|
): Promise<AuthContext> {
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
const { payload } = await jwtVerify(token, jwks, {
|
||||||
|
issuer: LOGTO_ISSUER,
|
||||||
|
audience: LOGTO_TEAMS_AUDIENCE,
|
||||||
|
});
|
||||||
|
if (!payload.sub || !hasManagerClaim(payload)) {
|
||||||
|
throw new GraphQLError("Manager access required", {
|
||||||
|
extensions: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
teamUuid: (payload as Record<string, unknown>).team_uuid as
|
||||||
|
| string
|
||||||
|
| undefined,
|
||||||
|
scopes: [...new Set([...scopesFromPayload(payload), "manager"])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
const { payload } = await jwtVerify(token, jwks, {
|
||||||
|
issuer: LOGTO_ISSUER,
|
||||||
|
audience: LOGTO_TEAMS_AUDIENCE,
|
||||||
|
});
|
||||||
|
const teamUuid = (payload as Record<string, unknown>).team_uuid as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const scopes = scopesFromPayload(payload);
|
||||||
|
if (!teamUuid || !scopes.includes("teams:member"))
|
||||||
|
throw new GraphQLError("Unauthorized", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
return { userId: payload.sub, teamUuid, scopes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function m2mContext(): Promise<AuthContext> {
|
||||||
|
return { scopes: [], isM2M: true };
|
||||||
|
}
|
||||||
|
|
||||||
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
|
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
|
||||||
const missing = required.filter(s => !ctx.scopes.includes(s))
|
const missing = required.filter((s) => !ctx.scopes.includes(s));
|
||||||
if (missing.length > 0) throw new GraphQLError(`Missing required scopes: ${missing.join(', ')}`, { extensions: { code: 'FORBIDDEN' } })
|
if (missing.length > 0)
|
||||||
|
throw new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, {
|
||||||
|
extensions: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
136
src/index.ts
136
src/index.ts
@@ -1,47 +1,115 @@
|
|||||||
import express from 'express'
|
import Fastify from "fastify";
|
||||||
import cors from 'cors'
|
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||||
import { ApolloServer } from '@apollo/server'
|
import cors from "@fastify/cors";
|
||||||
import { expressMiddleware } from '@apollo/server/express4'
|
import mercurius from "mercurius";
|
||||||
import * as Sentry from '@sentry/node'
|
import * as Sentry from "@sentry/node";
|
||||||
import { publicTypeDefs, publicResolvers } from './schemas/public.js'
|
import { publicTypeDefs, publicResolvers } from "./schemas/public.js";
|
||||||
import { userTypeDefs, userResolvers } from './schemas/user.js'
|
import { userTypeDefs, userResolvers } from "./schemas/user.js";
|
||||||
import { teamTypeDefs, teamResolvers } from './schemas/team.js'
|
import { teamTypeDefs, teamResolvers } from "./schemas/team.js";
|
||||||
import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js'
|
import { managerTypeDefs, managerResolvers } from "./schemas/manager.js";
|
||||||
import { publicContext, userContext, teamContext, m2mContext, type AuthContext } from './auth.js'
|
import { m2mTypeDefs, m2mResolvers } from "./schemas/m2m.js";
|
||||||
|
import {
|
||||||
|
publicContext,
|
||||||
|
userContext,
|
||||||
|
teamContext,
|
||||||
|
managerContext,
|
||||||
|
m2mContext,
|
||||||
|
} from "./auth.js";
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || '8000', 10)
|
const PORT = Number.parseInt(process.env.PORT || "8000", 10);
|
||||||
const SENTRY_DSN = process.env.SENTRY_DSN || ''
|
const SENTRY_DSN = process.env.SENTRY_DSN || "";
|
||||||
|
|
||||||
if (SENTRY_DSN) {
|
if (SENTRY_DSN) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: SENTRY_DSN,
|
dsn: SENTRY_DSN,
|
||||||
tracesSampleRate: 0.01,
|
tracesSampleRate: 0.01,
|
||||||
release: process.env.RELEASE_VERSION || '1.0.0',
|
release: process.env.RELEASE_VERSION || "1.0.0",
|
||||||
environment: process.env.ENVIRONMENT || 'production',
|
environment: process.env.ENVIRONMENT || "production",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express()
|
const app = Fastify();
|
||||||
app.use(cors({ origin: ['https://optovia.ru'], credentials: true }))
|
await app.register(cors, { origin: ["https://optovia.ru"], credentials: true });
|
||||||
|
|
||||||
const publicServer = new ApolloServer<AuthContext>({ typeDefs: publicTypeDefs, resolvers: publicResolvers, introspection: true })
|
type GraphqlBody = {
|
||||||
const userServer = new ApolloServer<AuthContext>({ typeDefs: userTypeDefs, resolvers: userResolvers, introspection: true })
|
query?: string;
|
||||||
const teamServer = new ApolloServer<AuthContext>({ typeDefs: teamTypeDefs, resolvers: teamResolvers, introspection: true })
|
variables?: Record<string, unknown>;
|
||||||
const m2mServer = new ApolloServer<AuthContext>({ typeDefs: m2mTypeDefs, resolvers: m2mResolvers, introspection: true })
|
operationName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
await Promise.all([publicServer.start(), userServer.start(), teamServer.start(), m2mServer.start()])
|
async function registerGraphqlEndpoint(
|
||||||
|
server: FastifyInstance,
|
||||||
|
path: string,
|
||||||
|
schema: string,
|
||||||
|
resolvers: unknown,
|
||||||
|
context: (request: FastifyRequest) => Promise<unknown> | unknown,
|
||||||
|
) {
|
||||||
|
await server.register(
|
||||||
|
async (route) => {
|
||||||
|
await route.register(mercurius, {
|
||||||
|
schema,
|
||||||
|
resolvers: resolvers as never,
|
||||||
|
routes: false,
|
||||||
|
});
|
||||||
|
route.post("/", async (request, reply) => {
|
||||||
|
const body = request.body as GraphqlBody;
|
||||||
|
if (typeof body.query !== "string") {
|
||||||
|
throw new Error("GraphQL query is required");
|
||||||
|
}
|
||||||
|
return reply.graphql(
|
||||||
|
body.query,
|
||||||
|
(await context(request)) as Record<string, unknown>,
|
||||||
|
body.variables,
|
||||||
|
body.operationName,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ prefix: path },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
app.use('/graphql/public', express.json(), expressMiddleware(publicServer, { context: async () => publicContext() }) as unknown as express.RequestHandler)
|
await registerGraphqlEndpoint(
|
||||||
app.use('/graphql/user', express.json(), expressMiddleware(userServer, { context: async ({ req }) => userContext(req as unknown as import('express').Request) }) as unknown as express.RequestHandler)
|
app,
|
||||||
app.use('/graphql/team', express.json(), expressMiddleware(teamServer, { context: async ({ req }) => teamContext(req as unknown as import('express').Request) }) as unknown as express.RequestHandler)
|
"/graphql/public",
|
||||||
app.use('/graphql/m2m', express.json(), expressMiddleware(m2mServer, { context: async () => m2mContext() }) as unknown as express.RequestHandler)
|
publicTypeDefs,
|
||||||
|
publicResolvers,
|
||||||
|
async () => publicContext(),
|
||||||
|
);
|
||||||
|
await registerGraphqlEndpoint(
|
||||||
|
app,
|
||||||
|
"/graphql/user",
|
||||||
|
userTypeDefs,
|
||||||
|
userResolvers,
|
||||||
|
userContext,
|
||||||
|
);
|
||||||
|
await registerGraphqlEndpoint(
|
||||||
|
app,
|
||||||
|
"/graphql/team",
|
||||||
|
teamTypeDefs,
|
||||||
|
teamResolvers,
|
||||||
|
teamContext,
|
||||||
|
);
|
||||||
|
await registerGraphqlEndpoint(
|
||||||
|
app,
|
||||||
|
"/graphql/manager",
|
||||||
|
managerTypeDefs,
|
||||||
|
managerResolvers,
|
||||||
|
managerContext,
|
||||||
|
);
|
||||||
|
await registerGraphqlEndpoint(
|
||||||
|
app,
|
||||||
|
"/graphql/m2m",
|
||||||
|
m2mTypeDefs,
|
||||||
|
m2mResolvers,
|
||||||
|
async () => m2mContext(),
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/health', (_, res) => { res.json({ status: 'ok' }) })
|
app.get("/health", async () => ({ status: "ok" }));
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
await app.listen({ port: PORT, host: "0.0.0.0" });
|
||||||
console.log(`Teams server ready on port ${PORT}`)
|
console.log(`Teams server ready on port ${PORT}`);
|
||||||
console.log(` /graphql/public - public`)
|
console.log(` /graphql/public - public`);
|
||||||
console.log(` /graphql/user - id token auth`)
|
console.log(` /graphql/user - id token auth`);
|
||||||
console.log(` /graphql/team - team access token auth`)
|
console.log(` /graphql/team - team access token auth`);
|
||||||
console.log(` /graphql/m2m - internal services (no auth)`)
|
console.log(` /graphql/manager - manager JWT auth`);
|
||||||
})
|
console.log(` /graphql/m2m - internal services (no auth)`);
|
||||||
|
|||||||
74
src/schemas/manager.ts
Normal file
74
src/schemas/manager.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
import { requireScopes, type AuthContext } from '../auth.js'
|
||||||
|
|
||||||
|
export const managerTypeDefs = `#graphql
|
||||||
|
type ManagerUser {
|
||||||
|
id: ID!
|
||||||
|
accountId: String
|
||||||
|
displayName: String
|
||||||
|
phone: String!
|
||||||
|
companyName: String
|
||||||
|
telegramGroupTitle: String
|
||||||
|
telegramNotificationsEnabled: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
managerUsers: [ManagerUser!]!
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function displayName(firstName: string, lastName: string, phone: string): string {
|
||||||
|
const name = `${firstName} ${lastName}`.trim()
|
||||||
|
return name.length > 0 ? name : phone
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isManagerUser(userId: number): Promise<boolean> {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||||
|
if (user === null) return false
|
||||||
|
if (user.isStaff || user.isSuperuser) return true
|
||||||
|
const managerMembership = await prisma.teamMember.findFirst({
|
||||||
|
where: { userId, role: { in: ['OWNER', 'MANAGER', 'ADMIN'] } },
|
||||||
|
})
|
||||||
|
return managerMembership !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireManagerProfile(ctx: AuthContext) {
|
||||||
|
requireScopes(ctx, 'manager')
|
||||||
|
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
||||||
|
const profile = await prisma.userProfile.findUnique({
|
||||||
|
where: { logtoId: ctx.userId },
|
||||||
|
include: { user: true, activeTeam: true },
|
||||||
|
})
|
||||||
|
if (profile === null || !(await isManagerUser(profile.userId))) {
|
||||||
|
throw new GraphQLError('Manager access required', { extensions: { code: 'FORBIDDEN' } })
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
export const managerResolvers = {
|
||||||
|
Query: {
|
||||||
|
managerUsers: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||||
|
const profile = await requireManagerProfile(ctx)
|
||||||
|
const members = await prisma.teamMember.findMany({
|
||||||
|
where: profile.activeTeamId === null ? {} : { teamId: profile.activeTeamId },
|
||||||
|
include: { user: { include: { profile: { include: { activeTeam: true } } } }, team: true },
|
||||||
|
orderBy: { joinedAt: 'desc' },
|
||||||
|
})
|
||||||
|
return members.filter(member => member.user !== null).map(member => {
|
||||||
|
const user = member.user!
|
||||||
|
const memberProfile = user.profile
|
||||||
|
const phone = memberProfile?.phone ?? ''
|
||||||
|
return {
|
||||||
|
id: member.uuid,
|
||||||
|
accountId: memberProfile?.logtoId ?? user.username,
|
||||||
|
displayName: displayName(user.firstName, user.lastName, phone),
|
||||||
|
phone,
|
||||||
|
companyName: member.team.name,
|
||||||
|
telegramGroupTitle: null,
|
||||||
|
telegramNotificationsEnabled: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from "graphql";
|
||||||
import { prisma } from '../db.js'
|
import { prisma } from "../db.js";
|
||||||
import type { AuthContext } from '../auth.js'
|
import { type AuthContext } from "../auth.js";
|
||||||
|
|
||||||
export const userTypeDefs = `#graphql
|
export const userTypeDefs = `#graphql
|
||||||
type UserTeam {
|
type UserTeam {
|
||||||
@@ -15,8 +15,10 @@ export const userTypeDefs = `#graphql
|
|||||||
id: String
|
id: String
|
||||||
firstName: String
|
firstName: String
|
||||||
lastName: String
|
lastName: String
|
||||||
|
displayName: String
|
||||||
phone: String
|
phone: String
|
||||||
avatarId: String
|
avatarId: String
|
||||||
|
isManager: Boolean
|
||||||
activeTeamId: String
|
activeTeamId: String
|
||||||
activeTeam: UserTeam
|
activeTeam: UserTeam
|
||||||
teams: [UserTeam]
|
teams: [UserTeam]
|
||||||
@@ -77,106 +79,229 @@ export const userTypeDefs = `#graphql
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
logout: Boolean!
|
||||||
createTeam(input: CreateTeamInput!): CreateTeamResult
|
createTeam(input: CreateTeamInput!): CreateTeamResult
|
||||||
updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult
|
updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult
|
||||||
switchTeam(teamId: String!): SwitchTeamResult
|
switchTeam(teamId: String!): SwitchTeamResult
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
async function getOrCreateProfile(logtoId: string) {
|
async function getOrCreateProfile(logtoId: string) {
|
||||||
let profile = await prisma.userProfile.findUnique({ where: { logtoId: logtoId }, include: { user: true, activeTeam: true } })
|
let profile = await prisma.userProfile.findUnique({
|
||||||
|
where: { logtoId: logtoId },
|
||||||
|
include: { user: true, activeTeam: true },
|
||||||
|
});
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } })
|
const user = await prisma.user.create({
|
||||||
|
data: { username: logtoId, firstName: "", lastName: "" },
|
||||||
|
});
|
||||||
profile = await prisma.userProfile.create({
|
profile = await prisma.userProfile.create({
|
||||||
data: { userId: user.id, logtoId: logtoId },
|
data: { userId: user.id, logtoId: logtoId },
|
||||||
include: { user: true, activeTeam: true },
|
include: { user: true, activeTeam: true },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return profile
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(
|
||||||
|
firstName: string,
|
||||||
|
lastName: string,
|
||||||
|
phone: string,
|
||||||
|
): string {
|
||||||
|
const name = `${firstName} ${lastName}`.trim();
|
||||||
|
return name.length > 0 ? name : phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isManagerUser(userId: number): Promise<boolean> {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (user === null) return false;
|
||||||
|
if (user.isStaff || user.isSuperuser) return true;
|
||||||
|
const managerMembership = await prisma.teamMember.findFirst({
|
||||||
|
where: { userId, role: { in: ["OWNER", "MANAGER", "ADMIN"] } },
|
||||||
|
});
|
||||||
|
return managerMembership !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapProfileUser(
|
||||||
|
profile: Awaited<ReturnType<typeof getOrCreateProfile>>,
|
||||||
|
) {
|
||||||
|
const memberships = await prisma.teamMember.findMany({
|
||||||
|
where: { userId: profile.userId },
|
||||||
|
include: { team: true },
|
||||||
|
});
|
||||||
|
const isManager = await isManagerUser(profile.userId);
|
||||||
|
return {
|
||||||
|
id: profile.logtoId,
|
||||||
|
firstName: profile.user.firstName,
|
||||||
|
lastName: profile.user.lastName,
|
||||||
|
displayName: displayName(
|
||||||
|
profile.user.firstName,
|
||||||
|
profile.user.lastName,
|
||||||
|
profile.phone,
|
||||||
|
),
|
||||||
|
phone: profile.phone,
|
||||||
|
avatarId: profile.avatarId,
|
||||||
|
isManager,
|
||||||
|
activeTeamId: profile.activeTeam?.uuid ?? null,
|
||||||
|
activeTeam: profile.activeTeam
|
||||||
|
? {
|
||||||
|
id: profile.activeTeam.uuid,
|
||||||
|
name: profile.activeTeam.name,
|
||||||
|
teamType: profile.activeTeam.teamType,
|
||||||
|
logtoOrgId: profile.activeTeam.logtoOrgId,
|
||||||
|
createdAt: profile.activeTeam.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
teams: memberships.map((m) => ({
|
||||||
|
id: m.team.uuid,
|
||||||
|
name: m.team.name,
|
||||||
|
teamType: m.team.teamType,
|
||||||
|
logtoOrgId: m.team.logtoOrgId,
|
||||||
|
createdAt: m.team.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userResolvers = {
|
export const userResolvers = {
|
||||||
Query: {
|
Query: {
|
||||||
me: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
me: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
if (!ctx.userId) throw new GraphQLError("Not authenticated");
|
||||||
const profile = await getOrCreateProfile(ctx.userId)
|
const profile = await getOrCreateProfile(ctx.userId);
|
||||||
const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } })
|
return mapProfileUser(profile);
|
||||||
return {
|
|
||||||
id: ctx.userId,
|
|
||||||
firstName: profile.user.firstName,
|
|
||||||
lastName: profile.user.lastName,
|
|
||||||
phone: profile.phone,
|
|
||||||
avatarId: profile.avatarId,
|
|
||||||
activeTeamId: profile.activeTeam?.uuid ?? null,
|
|
||||||
activeTeam: profile.activeTeam ? { id: profile.activeTeam.uuid, name: profile.activeTeam.name, teamType: profile.activeTeam.teamType, logtoOrgId: profile.activeTeam.logtoOrgId, createdAt: profile.activeTeam.createdAt.toISOString() } : null,
|
|
||||||
teams: memberships.map(m => ({ id: m.team.uuid, name: m.team.name, teamType: m.team.teamType, logtoOrgId: m.team.logtoOrgId, createdAt: m.team.createdAt.toISOString() })),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
if (!ctx.userId) throw new GraphQLError("Not authenticated");
|
||||||
const team = await prisma.team.findUnique({
|
const team = await prisma.team.findUnique({
|
||||||
where: { uuid: args.teamId },
|
where: { uuid: args.teamId },
|
||||||
include: {
|
include: {
|
||||||
members: { include: { user: true } },
|
members: { include: { user: true } },
|
||||||
invitations: true,
|
invitations: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
if (!team) return null
|
if (!team) return null;
|
||||||
return {
|
return {
|
||||||
uuid: team.uuid,
|
uuid: team.uuid,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
members: team.members.map(m => ({ uuid: m.uuid, role: m.role, joinedAt: m.joinedAt.toISOString() })),
|
members: team.members.map((m) => ({
|
||||||
invitations: team.invitations.map(i => ({
|
uuid: m.uuid,
|
||||||
uuid: i.uuid, email: i.email, role: i.role, status: i.status,
|
role: m.role,
|
||||||
invitedBy: i.invitedBy, expiresAt: i.expiresAt?.toISOString() ?? null,
|
joinedAt: m.joinedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
invitations: team.invitations.map((i) => ({
|
||||||
|
uuid: i.uuid,
|
||||||
|
email: i.email,
|
||||||
|
role: i.role,
|
||||||
|
status: i.status,
|
||||||
|
invitedBy: i.invitedBy,
|
||||||
|
expiresAt: i.expiresAt?.toISOString() ?? null,
|
||||||
createdAt: i.createdAt.toISOString(),
|
createdAt: i.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
Mutation: {
|
||||||
createTeam: async (_: unknown, args: { input: { name: string; teamType?: string } }, ctx: AuthContext) => {
|
logout: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
if (ctx.sessionToken !== undefined) {
|
||||||
const profile = await getOrCreateProfile(ctx.userId)
|
await prisma.authSession.updateMany({
|
||||||
|
where: { token: ctx.sessionToken, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
createTeam: async (
|
||||||
|
_: unknown,
|
||||||
|
args: { input: { name: string; teamType?: string } },
|
||||||
|
ctx: AuthContext,
|
||||||
|
) => {
|
||||||
|
if (!ctx.userId) throw new GraphQLError("Not authenticated");
|
||||||
|
const profile = await getOrCreateProfile(ctx.userId);
|
||||||
const team = await prisma.team.create({
|
const team = await prisma.team.create({
|
||||||
data: { name: args.input.name, teamType: args.input.teamType || 'BUYER', ownerId: profile.userId },
|
data: {
|
||||||
})
|
name: args.input.name,
|
||||||
await prisma.teamMember.create({ data: { teamId: team.id, userId: profile.userId, role: 'OWNER' } })
|
teamType: args.input.teamType || "BUYER",
|
||||||
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
|
ownerId: profile.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: { teamId: team.id, userId: profile.userId, role: "OWNER" },
|
||||||
|
});
|
||||||
|
await prisma.userProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: { activeTeamId: team.id },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
team: { id: team.uuid, name: team.name, teamType: team.teamType, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString() },
|
team: {
|
||||||
|
id: team.uuid,
|
||||||
|
name: team.name,
|
||||||
|
teamType: team.teamType,
|
||||||
|
logtoOrgId: team.logtoOrgId,
|
||||||
|
createdAt: team.createdAt.toISOString(),
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUser: async (_: unknown, args: { userId: string; input: { firstName?: string; lastName?: string; phone?: string; avatarId?: string } }, ctx: AuthContext) => {
|
updateUser: async (
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
_: unknown,
|
||||||
const profile = await getOrCreateProfile(ctx.userId)
|
args: {
|
||||||
const userUpdate: Record<string, unknown> = {}
|
userId: string;
|
||||||
if (args.input.firstName !== undefined) userUpdate.firstName = args.input.firstName
|
input: {
|
||||||
if (args.input.lastName !== undefined) userUpdate.lastName = args.input.lastName
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone?: string;
|
||||||
|
avatarId?: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
ctx: AuthContext,
|
||||||
|
) => {
|
||||||
|
if (!ctx.userId) throw new GraphQLError("Not authenticated");
|
||||||
|
const profile = await getOrCreateProfile(ctx.userId);
|
||||||
|
const userUpdate: Record<string, unknown> = {};
|
||||||
|
if (args.input.firstName !== undefined)
|
||||||
|
userUpdate.firstName = args.input.firstName;
|
||||||
|
if (args.input.lastName !== undefined)
|
||||||
|
userUpdate.lastName = args.input.lastName;
|
||||||
if (Object.keys(userUpdate).length > 0) {
|
if (Object.keys(userUpdate).length > 0) {
|
||||||
await prisma.user.update({ where: { id: profile.userId }, data: userUpdate })
|
await prisma.user.update({
|
||||||
|
where: { id: profile.userId },
|
||||||
|
data: userUpdate,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const profileUpdate: Record<string, unknown> = {}
|
const profileUpdate: Record<string, unknown> = {};
|
||||||
if (args.input.phone !== undefined) profileUpdate.phone = args.input.phone
|
if (args.input.phone !== undefined)
|
||||||
if (args.input.avatarId !== undefined) profileUpdate.avatarId = args.input.avatarId
|
profileUpdate.phone = args.input.phone;
|
||||||
|
if (args.input.avatarId !== undefined)
|
||||||
|
profileUpdate.avatarId = args.input.avatarId;
|
||||||
if (Object.keys(profileUpdate).length > 0) {
|
if (Object.keys(profileUpdate).length > 0) {
|
||||||
await prisma.userProfile.update({ where: { id: profile.id }, data: profileUpdate })
|
await prisma.userProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: profileUpdate,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { user: { id: ctx.userId }, success: true }
|
return { user: { id: ctx.userId }, success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
switchTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
switchTeam: async (
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
_: unknown,
|
||||||
const profile = await getOrCreateProfile(ctx.userId)
|
args: { teamId: string },
|
||||||
const team = await prisma.team.findUnique({ where: { uuid: args.teamId } })
|
ctx: AuthContext,
|
||||||
if (!team) throw new GraphQLError('Team not found')
|
) => {
|
||||||
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
|
if (!ctx.userId) throw new GraphQLError("Not authenticated");
|
||||||
return { success: true }
|
const profile = await getOrCreateProfile(ctx.userId);
|
||||||
|
const team = await prisma.team.findUnique({
|
||||||
|
where: { uuid: args.teamId },
|
||||||
|
});
|
||||||
|
if (!team) throw new GraphQLError("Team not found");
|
||||||
|
await prisma.userProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: { activeTeamId: team.id },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user