Migrate teams backend from Django to Express + Apollo Server + Prisma
Build Docker Image / build (push) Successful in 2m8s
Build Docker Image / build (push) Successful in 2m8s
Replace Django/Graphene stack with TypeScript Express server using Apollo Server v4 with 4 GraphQL endpoints (public/user/team/m2m) and Prisma ORM mapped to existing tables.
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireScopes, type AuthContext } from '../auth.js'
|
||||
import { startAddressWorkflow, startInviteWorkflow } from '../services/temporal.js'
|
||||
|
||||
export const teamTypeDefs = `#graphql
|
||||
type TeamUser {
|
||||
id: String
|
||||
firstName: String
|
||||
lastName: String
|
||||
phone: String
|
||||
avatarId: String
|
||||
createdAt: String
|
||||
}
|
||||
|
||||
type TeamMember {
|
||||
user: TeamUser
|
||||
role: String!
|
||||
joinedAt: String
|
||||
}
|
||||
|
||||
type TeamAddress {
|
||||
uuid: String!
|
||||
name: String!
|
||||
address: String!
|
||||
latitude: Float
|
||||
longitude: Float
|
||||
isDefault: Boolean!
|
||||
countryCode: String
|
||||
createdAt: String
|
||||
processedAt: String
|
||||
status: String!
|
||||
}
|
||||
|
||||
type SelectedLocation {
|
||||
type: String
|
||||
uuid: String
|
||||
name: String
|
||||
latitude: Float
|
||||
longitude: Float
|
||||
}
|
||||
|
||||
type Team {
|
||||
id: String!
|
||||
name: String!
|
||||
ownerId: String
|
||||
members: [TeamMember]
|
||||
addresses: [TeamAddress]
|
||||
selectedLocation: SelectedLocation
|
||||
}
|
||||
|
||||
input InviteMemberInput {
|
||||
email: String!
|
||||
role: String
|
||||
}
|
||||
|
||||
input CreateTeamAddressInput {
|
||||
name: String!
|
||||
address: String!
|
||||
latitude: Float
|
||||
longitude: Float
|
||||
countryCode: String
|
||||
isDefault: Boolean
|
||||
}
|
||||
|
||||
input UpdateTeamAddressInput {
|
||||
uuid: String!
|
||||
name: String
|
||||
address: String
|
||||
latitude: Float
|
||||
longitude: Float
|
||||
countryCode: String
|
||||
isDefault: Boolean
|
||||
}
|
||||
|
||||
input SetSelectedLocationInput {
|
||||
type: String!
|
||||
uuid: String!
|
||||
name: String!
|
||||
latitude: Float!
|
||||
longitude: Float!
|
||||
}
|
||||
|
||||
type InviteMemberResult { success: Boolean!, message: String }
|
||||
type CreateTeamAddressResult { success: Boolean!, message: String, workflowId: String }
|
||||
type UpdateTeamAddressResult { success: Boolean!, address: TeamAddress }
|
||||
type DeleteTeamAddressResult { success: Boolean! }
|
||||
type SetSelectedLocationResult { success: Boolean! }
|
||||
|
||||
type Query {
|
||||
team: Team
|
||||
getTeam(teamId: String!): Team
|
||||
teamMembers: [TeamMember]
|
||||
teamAddresses: [TeamAddress]
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
inviteMember(input: InviteMemberInput!): InviteMemberResult
|
||||
createTeamAddress(input: CreateTeamAddressInput!): CreateTeamAddressResult
|
||||
updateTeamAddress(input: UpdateTeamAddressInput!): UpdateTeamAddressResult
|
||||
deleteTeamAddress(uuid: String!): DeleteTeamAddressResult
|
||||
setSelectedLocation(input: SetSelectedLocationInput!): SetSelectedLocationResult
|
||||
}
|
||||
`
|
||||
|
||||
async function getTeamByUuid(uuid: string) {
|
||||
return prisma.team.findUnique({
|
||||
where: { uuid },
|
||||
include: {
|
||||
members: { include: { user: { include: { profile: true } } } },
|
||||
addresses: { orderBy: { createdAt: 'desc' } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mapTeam(team: NonNullable<Awaited<ReturnType<typeof getTeamByUuid>>>) {
|
||||
return {
|
||||
id: team.uuid,
|
||||
name: team.name,
|
||||
ownerId: team.ownerId?.toString() ?? null,
|
||||
members: team.members.map(m => ({
|
||||
user: m.user ? {
|
||||
id: m.user.profile?.logtoId ?? m.user.id.toString(),
|
||||
firstName: m.user.firstName,
|
||||
lastName: m.user.lastName,
|
||||
phone: m.user.profile?.phone ?? '',
|
||||
avatarId: m.user.profile?.avatarId ?? null,
|
||||
createdAt: m.user.dateJoined.toISOString(),
|
||||
} : null,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt.toISOString(),
|
||||
})),
|
||||
addresses: team.addresses.map(a => ({
|
||||
uuid: a.uuid, name: a.name, address: a.address,
|
||||
latitude: a.latitude, longitude: a.longitude,
|
||||
isDefault: a.isDefault, countryCode: a.countryCode,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
processedAt: a.processedAt?.toISOString() ?? null,
|
||||
status: a.status,
|
||||
})),
|
||||
selectedLocation: team.selectedLocationType ? {
|
||||
type: team.selectedLocationType,
|
||||
uuid: team.selectedLocationUuid,
|
||||
name: team.selectedLocationName,
|
||||
latitude: team.selectedLocationLat,
|
||||
longitude: team.selectedLocationLon,
|
||||
} : null,
|
||||
}
|
||||
}
|
||||
|
||||
export const teamResolvers = {
|
||||
Query: {
|
||||
team: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||
requireScopes(ctx, 'teams:member')
|
||||
if (!ctx.teamUuid) throw new GraphQLError('Team UUID not found')
|
||||
const team = await getTeamByUuid(ctx.teamUuid)
|
||||
return team ? mapTeam(team) : null
|
||||
},
|
||||
|
||||
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
||||
requireScopes(ctx, 'teams:member')
|
||||
const team = await getTeamByUuid(args.teamId)
|
||||
return team ? mapTeam(team) : null
|
||||
},
|
||||
|
||||
teamMembers: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||
requireScopes(ctx, 'teams:member')
|
||||
if (!ctx.teamUuid) return []
|
||||
const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } })
|
||||
if (!team) return []
|
||||
const members = await prisma.teamMember.findMany({
|
||||
where: { teamId: team.id },
|
||||
include: { user: { include: { profile: true } } },
|
||||
})
|
||||
return members.map(m => ({
|
||||
user: m.user ? {
|
||||
id: m.user.profile?.logtoId ?? m.user.id.toString(),
|
||||
firstName: m.user.firstName, lastName: m.user.lastName,
|
||||
phone: m.user.profile?.phone ?? '', avatarId: m.user.profile?.avatarId ?? null,
|
||||
createdAt: m.user.dateJoined.toISOString(),
|
||||
} : null,
|
||||
role: m.role, joinedAt: m.joinedAt.toISOString(),
|
||||
}))
|
||||
},
|
||||
|
||||
teamAddresses: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||
requireScopes(ctx, 'teams:member')
|
||||
if (!ctx.teamUuid) return []
|
||||
const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } })
|
||||
if (!team) return []
|
||||
const addrs = await prisma.teamAddress.findMany({ where: { teamId: team.id }, orderBy: { createdAt: 'desc' } })
|
||||
return addrs.map(a => ({
|
||||
uuid: a.uuid, name: a.name, address: a.address,
|
||||
latitude: a.latitude, longitude: a.longitude,
|
||||
isDefault: a.isDefault, countryCode: a.countryCode,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
processedAt: a.processedAt?.toISOString() ?? null,
|
||||
status: a.status,
|
||||
}))
|
||||
},
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
inviteMember: async (_: unknown, args: { input: { email: string; role?: string } }, ctx: AuthContext) => {
|
||||
requireScopes(ctx, 'teams:member')
|
||||
if (!ctx.teamUuid || !ctx.userId) throw new GraphQLError('Not authenticated')
|
||||
try {
|
||||
await startInviteWorkflow(ctx.teamUuid, args.input.email, args.input.role || 'MEMBER', ctx.userId)
|
||||
return { success: true, message: 'Invitation workflow started' }
|
||||
} catch (e) {
|
||||
console.error('Failed to start invite workflow:', e)
|
||||
return { success: false, message: String(e) }
|
||||
}
|
||||
},
|
||||
|
||||
createTeamAddress: async (_: unknown, args: { input: { name: string; address: string; latitude?: number; longitude?: number; countryCode?: string; isDefault?: boolean } }, ctx: AuthContext) => {
|
||||
requireScopes(ctx, 'teams:member')
|
||||
if (!ctx.teamUuid) throw new GraphQLError('Not authenticated')
|
||||
const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } })
|
||||
if (!team) throw new GraphQLError('Team not found')
|
||||
const addr = await prisma.teamAddress.create({
|
||||
data: {
|
||||
teamId: team.id, name: args.input.name, address: args.input.address,
|
||||
latitude: args.input.latitude, longitude: args.input.longitude,
|
||||
countryCode: args.input.countryCode || '', isDefault: args.input.isDefault || false,
|
||||
},
|
||||
})
|
||||
try {
|
||||
const wfId = await startAddressWorkflow(
|
||||
ctx.teamUuid, addr.uuid, addr.name, addr.address,
|
||||
addr.latitude ?? undefined, addr.longitude ?? undefined,
|
||||
addr.countryCode || undefined, addr.isDefault,
|
||||
)
|
||||
return { success: true, message: 'Address created', workflowId: wfId }
|
||||
} catch (e) {
|
||||
console.error('Failed to start address workflow:', e)
|
||||
return { success: true, message: 'Address created but workflow failed', workflowId: null }
|
||||
}
|
||||
},
|
||||
|
||||
updateTeamAddress: async (_: unknown, args: { input: { uuid: string; name?: string; address?: string; latitude?: number; longitude?: number; countryCode?: string; isDefault?: boolean } }, ctx: AuthContext) => {
|
||||
requireScopes(ctx, 'teams:member')
|
||||
const data: Record<string, unknown> = {}
|
||||
if (args.input.name !== undefined) data.name = args.input.name
|
||||
if (args.input.address !== undefined) data.address = args.input.address
|
||||
if (args.input.latitude !== undefined) data.latitude = args.input.latitude
|
||||
if (args.input.longitude !== undefined) data.longitude = args.input.longitude
|
||||
if (args.input.countryCode !== undefined) data.countryCode = args.input.countryCode
|
||||
if (args.input.isDefault !== undefined) data.isDefault = args.input.isDefault
|
||||
const addr = await prisma.teamAddress.update({ where: { uuid: args.input.uuid }, data })
|
||||
return {
|
||||
success: true,
|
||||
address: {
|
||||
uuid: addr.uuid, name: addr.name, address: addr.address,
|
||||
latitude: addr.latitude, longitude: addr.longitude,
|
||||
isDefault: addr.isDefault, countryCode: addr.countryCode,
|
||||
createdAt: addr.createdAt.toISOString(),
|
||||
processedAt: addr.processedAt?.toISOString() ?? null,
|
||||
status: addr.status,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
deleteTeamAddress: async (_: unknown, args: { uuid: string }, ctx: AuthContext) => {
|
||||
requireScopes(ctx, 'teams:member')
|
||||
await prisma.teamAddress.delete({ where: { uuid: args.uuid } })
|
||||
return { success: true }
|
||||
},
|
||||
|
||||
setSelectedLocation: async (_: unknown, args: { input: { type: string; uuid: string; name: string; latitude: number; longitude: number } }, ctx: AuthContext) => {
|
||||
requireScopes(ctx, 'teams:member')
|
||||
if (!ctx.teamUuid) throw new GraphQLError('Not authenticated')
|
||||
await prisma.team.update({
|
||||
where: { uuid: ctx.teamUuid },
|
||||
data: {
|
||||
selectedLocationType: args.input.type,
|
||||
selectedLocationUuid: args.input.uuid,
|
||||
selectedLocationName: args.input.name,
|
||||
selectedLocationLat: args.input.latitude,
|
||||
selectedLocationLon: args.input.longitude,
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user