diff --git a/Dockerfile b/Dockerfile index a2256cfa..1c8850e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM public.ecr.aws/docker/library/node:lts +FROM node:lts WORKDIR /usr/src/app ENV PNPM_HOME="/pnpm" diff --git a/Dockerfile.prod b/Dockerfile.prod index 3dac246d..33c26e1e 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -1,4 +1,4 @@ -FROM public.ecr.aws/docker/library/node:lts-slim AS base +FROM node:lts-slim AS base WORKDIR /usr/src/app LABEL org.opencontainers.image.source https://github.com/rubberdok/indok-api diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d2eded09..9e558eb6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -269,6 +269,7 @@ model Organization { description String @default("") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + colorScheme String? events Event[] members Member[] diff --git a/schema.graphql b/schema.graphql index 583b42d4..2f7715e7 100644 --- a/schema.graphql +++ b/schema.graphql @@ -270,6 +270,11 @@ type CreateOrderResponse { } input CreateOrganizationInput { + """ + The primary color (hex) for the organization, for example: #FF0000 for red. + """ + colorScheme: String + """The description of the organization, cannot exceed 10 000 characters""" description: String @@ -858,6 +863,9 @@ type Mutation { Passing null or omitting a value will leave the value unchanged. """ updateOrganization(data: UpdateOrganizationInput!): UpdateOrganizationResponse! + + """Change the role of a member in the organization""" + updateRole(data: UpdateRoleInput!): UpdateRoleResponse! updateUser(data: UpdateUserInput!): UpdateUserResponse! uploadFile(data: UploadFileInput!): UploadFileResponse! } @@ -969,6 +977,10 @@ type OrdersResponse { } type Organization { + """ + The primary color (hex) for the organization, for example: #FF0000 for red. + """ + colorScheme: String description: String! events: [Event!]! @@ -1571,6 +1583,11 @@ type UpdateListingResponse { } input UpdateOrganizationInput { + """ + The primary color (hex) for the organization, for example: #FF0000 for red. + """ + colorScheme: String + """ The new description of the organization, cannot exceed 10 000 characters Omitting the value or passing null will leave the description unchanged @@ -1598,6 +1615,18 @@ type UpdateOrganizationResponse { organization: Organization! } +input UpdateRoleInput { + """The ID of the member to change the role of""" + memberId: ID! + + """The new role of the member""" + role: Role! +} + +type UpdateRoleResponse { + member: Member! +} + input UpdateSlotInput { capacity: Int gradeYears: [Int!] diff --git a/src/domain/organizations.ts b/src/domain/organizations.ts index b8f093a5..f6185fa2 100644 --- a/src/domain/organizations.ts +++ b/src/domain/organizations.ts @@ -19,6 +19,7 @@ class Organization { id: string; name: string; description: string; + colorScheme?: string | null; logoFileId?: string | null; featurePermissions: FeaturePermissionType[]; @@ -28,6 +29,7 @@ class Organization { this.description = params.description; this.logoFileId = params.logoFileId; this.featurePermissions = params.featurePermissions; + this.colorScheme = params.colorScheme; } } diff --git a/src/graphql/organizations/resolvers/Mutation/createOrganization.ts b/src/graphql/organizations/resolvers/Mutation/createOrganization.ts index db84568e..fc4599e0 100644 --- a/src/graphql/organizations/resolvers/Mutation/createOrganization.ts +++ b/src/graphql/organizations/resolvers/Mutation/createOrganization.ts @@ -2,7 +2,7 @@ import type { MutationResolvers } from "./../../../types.generated.js"; export const createOrganization: NonNullable< MutationResolvers["createOrganization"] > = async (_parent, { data }, ctx) => { - const { name, description, featurePermissions } = data; + const { name, description, featurePermissions, colorScheme } = data; ctx.log.info( { name, description, featurePermissions }, "Creating organization", @@ -11,6 +11,7 @@ export const createOrganization: NonNullable< name, description, featurePermissions, + colorScheme, }); return { organization, diff --git a/src/graphql/organizations/resolvers/Mutation/updateOrganization.ts b/src/graphql/organizations/resolvers/Mutation/updateOrganization.ts index ad9e344e..aaa63578 100644 --- a/src/graphql/organizations/resolvers/Mutation/updateOrganization.ts +++ b/src/graphql/organizations/resolvers/Mutation/updateOrganization.ts @@ -2,13 +2,15 @@ import type { MutationResolvers } from "./../../../types.generated.js"; export const updateOrganization: NonNullable< MutationResolvers["updateOrganization"] > = async (_parent, { data }, ctx) => { - const { id, name, description, featurePermissions, logoFileId } = data; + const { id, name, description, featurePermissions, logoFileId, colorScheme } = + data; const organization = await ctx.organizations.organizations.update(ctx, id, { name, description, featurePermissions, logoFileId, + colorScheme, }); return { organization }; }; diff --git a/src/graphql/organizations/resolvers/Mutation/updateRole.ts b/src/graphql/organizations/resolvers/Mutation/updateRole.ts new file mode 100644 index 00000000..06418f93 --- /dev/null +++ b/src/graphql/organizations/resolvers/Mutation/updateRole.ts @@ -0,0 +1,16 @@ +import type { MutationResolvers } from "./../../../types.generated.js"; +export const updateRole: NonNullable = async ( + _parent, + { data }, + ctx, +) => { + const result = await ctx.organizations.members.updateRole(ctx, { + memberId: data.memberId, + newRole: data.role, + }); + + if (!result.ok) { + throw result.error; + } + return result.data; +}; diff --git a/src/graphql/organizations/resolvers/Mutation/updateRole.unit.test.ts b/src/graphql/organizations/resolvers/Mutation/updateRole.unit.test.ts new file mode 100644 index 00000000..82bbe7ed --- /dev/null +++ b/src/graphql/organizations/resolvers/Mutation/updateRole.unit.test.ts @@ -0,0 +1,84 @@ +import { faker } from "@faker-js/faker"; +import { InternalServerError } from "~/domain/errors.js"; +import { + OrganizationMember, + OrganizationRole, +} from "~/domain/organizations.js"; +import { createMockApolloServer } from "~/graphql/test-clients/mock-apollo-server.js"; +import { graphql } from "~/graphql/test-clients/unit/gql.js"; +import { Result } from "~/lib/result.js"; + +describe("Organiation mutations", () => { + describe("updateRole", () => { + it("updates a role", async () => { + const { client, organizationService } = createMockApolloServer(); + + organizationService.members.updateRole.mockResolvedValue({ + ok: true, + data: { + member: new OrganizationMember({ + id: faker.string.uuid(), + role: OrganizationRole.ADMIN, + organizationId: faker.string.uuid(), + userId: faker.string.uuid(), + }), + }, + }); + + const { errors, data } = await client.mutate({ + mutation: graphql(` + mutation UpdateRole($data: UpdateRoleInput!) { + updateRole(data: $data) { + member { + id + } + } + } + `), + variables: { + data: { + memberId: faker.string.uuid(), + role: OrganizationRole.ADMIN, + }, + }, + }); + + expect(errors).toBeUndefined(); + expect(data).toEqual({ + updateRole: { + member: { + id: expect.any(String), + }, + }, + }); + }); + + it("throws on error", async () => { + const { client, organizationService } = createMockApolloServer(); + + organizationService.members.updateRole.mockResolvedValue( + Result.error(new InternalServerError("")), + ); + + const { errors } = await client.mutate({ + mutation: graphql(` + mutation UpdateRole($data: UpdateRoleInput!) { + updateRole(data: $data) { + member { + id + } + } + } + `), + variables: { + data: { + memberId: faker.string.uuid(), + role: OrganizationRole.ADMIN, + }, + }, + }); + + expect(errors).toBeDefined(); + }); + }); +}); diff --git a/src/graphql/organizations/resolvers/UpdateRoleResponse.ts b/src/graphql/organizations/resolvers/UpdateRoleResponse.ts new file mode 100644 index 00000000..4e77b5e8 --- /dev/null +++ b/src/graphql/organizations/resolvers/UpdateRoleResponse.ts @@ -0,0 +1,4 @@ +import type { UpdateRoleResponseResolvers } from "./../../types.generated.js"; +export const UpdateRoleResponse: UpdateRoleResponseResolvers = { + /* Implement UpdateRoleResponse resolver logic here */ +}; diff --git a/src/graphql/organizations/schema.graphql b/src/graphql/organizations/schema.graphql index da507c13..24f481e0 100644 --- a/src/graphql/organizations/schema.graphql +++ b/src/graphql/organizations/schema.graphql @@ -34,6 +34,11 @@ type Mutation { Remove a member from the organization by the ID of the membership. """ removeMember(data: RemoveMemberInput!): RemoveMemberResponse! + + """ + Change the role of a member in the organization + """ + updateRole(data: UpdateRoleInput!): UpdateRoleResponse! } type Organization { @@ -52,6 +57,10 @@ type Organization { events: [Event!]! listings: [Listing!]! logo: RemoteFile + """ + The primary color (hex) for the organization, for example: #FF0000 for red. + """ + colorScheme: String } type Member { @@ -86,6 +95,21 @@ enum Role { MEMBER } +input UpdateRoleInput { + """ + The ID of the member to change the role of + """ + memberId: ID! + """ + The new role of the member + """ + role: Role! +} + +type UpdateRoleResponse { + member: Member! +} + input UpdateOrganizationInput { """ The ID of the organization to update @@ -107,6 +131,10 @@ input UpdateOrganizationInput { """ featurePermissions: [FeaturePermission!] logoFileId: ID + """ + The primary color (hex) for the organization, for example: #FF0000 for red. + """ + colorScheme: String } type UpdateOrganizationResponse { @@ -127,6 +155,10 @@ input CreateOrganizationInput { Requires that the current user is a super user, otherwise, this field is ignored. """ featurePermissions: [FeaturePermission!] + """ + The primary color (hex) for the organization, for example: #FF0000 for red. + """ + colorScheme: String } type CreateOrganizationResponse { diff --git a/src/lib/server.ts b/src/lib/server.ts index 52920361..0199f706 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -98,6 +98,7 @@ interface IOrganizationService { name: string; description?: string | null; featurePermissions?: FeaturePermissionType[] | null; + colorScheme?: string | null; }, ): Promise; update( @@ -108,6 +109,7 @@ interface IOrganizationService { description: string | null; featurePermissions: FeaturePermissionType[] | null; logoFileId: string | null; + colorScheme: string | null; }>, ): Promise; get(id: string): Promise; @@ -149,6 +151,17 @@ interface IOrganizationService { { members: OrganizationMember[] }, PermissionDeniedError | UnauthorizedError >; + updateRole( + ctx: Context, + params: { memberId: string; newRole: OrganizationRoleType }, + ): ResultAsync< + { member: OrganizationMember }, + | PermissionDeniedError + | UnauthorizedError + | InternalServerError + | InvalidArgumentErrorV2 + | NotFoundError + >; }; permissions: { hasFeaturePermission( diff --git a/src/repositories/organizations/__tests__/integration/members.test.ts b/src/repositories/organizations/members.integration.test.ts similarity index 88% rename from src/repositories/organizations/__tests__/integration/members.test.ts rename to src/repositories/organizations/members.integration.test.ts index e40f55ce..b77b142f 100644 --- a/src/repositories/organizations/__tests__/integration/members.test.ts +++ b/src/repositories/organizations/members.integration.test.ts @@ -4,9 +4,10 @@ import { type OrganizationMember, OrganizationRole, } from "~/domain/organizations.js"; +import { makeMockContext } from "~/lib/context.js"; import prisma from "~/lib/prisma.js"; import { Result } from "~/lib/result.js"; -import { MemberRepository } from "../../members.js"; +import { MemberRepository } from "./members.js"; let repo: MemberRepository; @@ -469,4 +470,63 @@ describe("MembersRepository", () => { ); }); }); + + describe("#updateRole", () => { + it("updates the role of the member", async () => { + /** + * Arrange. + * + * 1. Create a user with userId {userId} + * 2. Create an organization with organizationId {organizationId} + * 3. Create a membership with id {id} for the user {userId} in the organization {organizationId} + */ + // 1. + const user = await prisma.user.create({ + data: { + email: faker.internet.email(), + feideId: faker.string.uuid(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + username: faker.internet.userName(), + }, + }); + // 2. + const organization = await prisma.organization.create({ + data: { + name: faker.string.sample(), + }, + }); + // 3. + const member = await prisma.member.create({ + data: { + userId: user.id, + organizationId: organization.id, + role: OrganizationRole.MEMBER, + }, + }); + + // Act + const result = await repo.updateRole(makeMockContext(), { + memberId: member.id, + role: OrganizationRole.ADMIN, + }); + + expect(result).toEqual({ + ok: true, + data: { + member: expect.objectContaining({ role: OrganizationRole.ADMIN }), + }, + }); + }); + + it("returns NotFoundError if the member does not exist", async () => { + // Act + const result = await repo.updateRole(makeMockContext(), { + memberId: faker.string.uuid(), + role: OrganizationRole.ADMIN, + }); + + expect(result).toEqual(Result.error(expect.any(NotFoundError))); + }); + }); }); diff --git a/src/repositories/organizations/members.ts b/src/repositories/organizations/members.ts index fd593740..e40fa240 100644 --- a/src/repositories/organizations/members.ts +++ b/src/repositories/organizations/members.ts @@ -9,6 +9,7 @@ import { OrganizationMember, type OrganizationRoleType, } from "~/domain/organizations.js"; +import type { Context } from "~/lib/context.js"; import { prismaKnownErrorCodes } from "~/lib/prisma.js"; import { Result, type ResultAsync } from "~/lib/result.js"; import type { MemberRepository as IMemberRepository } from "~/services/organizations/service.js"; @@ -16,6 +17,40 @@ import type { MemberRepository as IMemberRepository } from "~/services/organizat export class MemberRepository implements IMemberRepository { constructor(private db: PrismaClient) {} + async updateRole( + ctx: Context, + data: { memberId: string; role: OrganizationRoleType }, + ): ResultAsync< + { member: OrganizationMember }, + InternalServerError | NotFoundError + > { + ctx.log.info({ data }, "updating member role"); + const { memberId, role } = data; + try { + const updatedMember = await this.db.member.update({ + where: { + id: memberId, + }, + data: { + role, + }, + }); + + return Result.success({ member: updatedMember }); + } catch (err) { + if (err instanceof PrismaClientKnownRequestError) { + if (err.code === prismaKnownErrorCodes.ERR_NOT_FOUND) { + return Result.error( + new NotFoundError("The membership does not exist."), + ); + } + } + return Result.error( + new InternalServerError("failed to update member role", err), + ); + } + } + findMany(where?: { organizationId?: string; userId?: string }): Promise< OrganizationMember[] > { diff --git a/src/repositories/organizations/__tests__/integration/organizations.test.ts b/src/repositories/organizations/organizations.integration.test.ts similarity index 99% rename from src/repositories/organizations/__tests__/integration/organizations.test.ts rename to src/repositories/organizations/organizations.integration.test.ts index 64a72d70..3bb4c386 100644 --- a/src/repositories/organizations/__tests__/integration/organizations.test.ts +++ b/src/repositories/organizations/organizations.integration.test.ts @@ -7,7 +7,7 @@ import { OrganizationRole, } from "~/domain/organizations.js"; import prisma from "~/lib/prisma.js"; -import { OrganizationRepository } from "../../organizations.js"; +import { OrganizationRepository } from "./organizations.js"; let organizationRepository: OrganizationRepository; diff --git a/src/repositories/organizations/organizations.ts b/src/repositories/organizations/organizations.ts index cb1ae8bc..5da30a7b 100644 --- a/src/repositories/organizations/organizations.ts +++ b/src/repositories/organizations/organizations.ts @@ -35,6 +35,7 @@ export class OrganizationRepository { description?: string; userId: string; featurePermissions?: FeaturePermission[]; + colorScheme?: string; }): Promise { const { userId, ...rest } = data; return this.db.organization @@ -74,14 +75,16 @@ export class OrganizationRepository { */ update( id: string, - data: { - name?: string; - description?: string; - featurePermissions?: FeaturePermission[]; - logoFileId?: string; - }, + data: Partial<{ + name: string; + description: string; + featurePermissions: FeaturePermission[]; + logoFileId: string; + colorScheme: string; + }>, ): Promise { - const { name, description, featurePermissions, logoFileId } = data; + const { name, description, featurePermissions, logoFileId, colorScheme } = + data; return this.db.organization.update({ where: { id }, data: { @@ -89,6 +92,7 @@ export class OrganizationRepository { description, featurePermissions, logoFileId, + colorScheme, }, }); } diff --git a/src/services/organizations/__tests__/integration/remove-member.test.ts b/src/services/organizations/__tests__/integration/members.test.ts similarity index 70% rename from src/services/organizations/__tests__/integration/remove-member.test.ts rename to src/services/organizations/__tests__/integration/members.test.ts index c499c4e1..08332716 100644 --- a/src/services/organizations/__tests__/integration/remove-member.test.ts +++ b/src/services/organizations/__tests__/integration/members.test.ts @@ -3,11 +3,17 @@ import { faker } from "@faker-js/faker"; import { makeTestServices } from "~/__tests__/dependencies-factory.js"; import { InvalidArgumentError, + InvalidArgumentErrorV2, + NotFoundError, PermissionDeniedError, UnauthorizedError, } from "~/domain/errors.js"; -import { OrganizationMember } from "~/domain/organizations.js"; +import { + OrganizationMember, + OrganizationRole, +} from "~/domain/organizations.js"; import { makeMockContext } from "~/lib/context.js"; +import { Result } from "~/lib/result.js"; describe("OrganizationService", () => { describe("#removeMember", () => { @@ -161,6 +167,88 @@ describe("OrganizationService", () => { }); }); }); + + describe("#updateRole", () => { + it("returns UnauthorizedError if not logged in", async () => { + const { organizationService } = await makeDeps(); + + const result = await organizationService.members.updateRole( + makeMockContext(null), + { + memberId: faker.string.uuid(), + newRole: OrganizationRole.ADMIN, + }, + ); + + expect(result).toEqual(Result.error(expect.any(UnauthorizedError))); + }); + + it("returns PermissionDeniedError if the user is not an admin in the organization", async () => { + const { memberUser, organizationService, adminUserMembership } = + await makeDeps(); + + const result = await organizationService.members.updateRole( + makeMockContext(memberUser), + { + memberId: adminUserMembership.id, + newRole: OrganizationRole.ADMIN, + }, + ); + + expect(result).toEqual(Result.error(expect.any(PermissionDeniedError))); + }); + + it("returns InvalidArgumentError if the user tries to change their own role", async () => { + const { adminUser, organizationService, adminUserMembership } = + await makeDeps(); + + const result = await organizationService.members.updateRole( + makeMockContext(adminUser), + { + memberId: adminUserMembership.id, + newRole: OrganizationRole.ADMIN, + }, + ); + + expect(result).toEqual(Result.error(expect.any(InvalidArgumentErrorV2))); + }); + + it("returns NotFoundError if the member does not exist", async () => { + const { adminUser, organizationService } = await makeDeps(); + + const result = await organizationService.members.updateRole( + makeMockContext(adminUser), + { + memberId: faker.string.uuid(), + newRole: OrganizationRole.ADMIN, + }, + ); + + expect(result).toEqual(Result.error(expect.any(NotFoundError))); + }); + + it("changes the role of the member", async () => { + const { adminUser, organizationService, memberUserMembership } = + await makeDeps(); + + const result = await organizationService.members.updateRole( + makeMockContext(adminUser), + { + memberId: memberUserMembership.id, + newRole: OrganizationRole.ADMIN, + }, + ); + + expect(result).toEqual( + Result.success({ + member: { + ...memberUserMembership, + role: OrganizationRole.ADMIN, + }, + }), + ); + }); + }); }); async function makeDeps() { diff --git a/src/services/organizations/__tests__/unit/organizations.test.ts b/src/services/organizations/__tests__/unit/organizations.test.ts index 4bc17dc2..ca20db2a 100644 --- a/src/services/organizations/__tests__/unit/organizations.test.ts +++ b/src/services/organizations/__tests__/unit/organizations.test.ts @@ -59,6 +59,7 @@ describe("OrganizationService", () => { organizationId: string; name?: string; description?: string; + colorScheme?: string; }; expectedError: unknown; }[] = [ @@ -98,6 +99,18 @@ describe("OrganizationService", () => { }, expectedError: expect.any(InvalidArgumentError), }, + { + name: "colorScheme is not a valid hex code", + state: { + user: mock({ id: "1", isSuperUser: false }), + role: OrganizationRole.MEMBER, + }, + input: { + organizationId: "o1", + colorScheme: "#gg0000", + }, + expectedError: expect.any(InvalidArgumentError), + }, ]; test.each(testCases)( @@ -117,15 +130,12 @@ describe("OrganizationService", () => { * We call the update method with the user ID, organization ID, and the * new organization name. */ - const { organizationId, name, description } = input; + const { organizationId, ...data } = input; try { await organizationService.organizations.update( makeMockContext(state.user), organizationId, - { - name, - description, - }, + data, ); fail("Expected to throw"); } catch (err) { @@ -145,6 +155,7 @@ describe("OrganizationService", () => { organizationId: string; name?: string; description?: string; + colorScheme?: string; }; }[] = [ { @@ -180,6 +191,16 @@ describe("OrganizationService", () => { description: faker.company.catchPhrase(), }, }, + { + state: { + user: mock({ id: "1", isSuperUser: true }), + role: null, + }, + input: { + organizationId: "o1", + colorScheme: "#000", + }, + }, ]; test.each(testCases)( @@ -199,11 +220,11 @@ describe("OrganizationService", () => { * We call the update method with the user ID, organization ID, and the * new organization name. */ - const { organizationId, name, description } = input; + const { organizationId, ...data } = input; const actual = organizationService.organizations.update( makeMockContext(state.user), organizationId, - { name, description }, + data, ); /** @@ -219,10 +240,7 @@ describe("OrganizationService", () => { await expect(actual).resolves.not.toThrow(); expect(organizationRepository.update).toHaveBeenCalledWith( input.organizationId, - { - name: input.name, - description: input.description, - }, + data, ); }, ); @@ -240,6 +258,7 @@ describe("OrganizationService", () => { userId: string; name: string; description?: string; + colorScheme?: string; }; expectedError: typeof KnownDomainError.name; }[] = [ @@ -277,6 +296,18 @@ describe("OrganizationService", () => { }, expectedError: InvalidArgumentError.name, }, + { + name: "colorScheme is not a valid color hex", + state: { + user: mock({ id: "1", isSuperUser: false }), + }, + input: { + userId: "1", + name: faker.company.name(), + colorScheme: "#fg0000", + }, + expectedError: InvalidArgumentError.name, + }, ]; test.each(testCases)( @@ -320,6 +351,7 @@ describe("OrganizationService", () => { userId: string; name: string; description?: string; + colorScheme?: string; }; }[] = [ { @@ -343,6 +375,17 @@ describe("OrganizationService", () => { description: faker.company.catchPhrase(), }, }, + { + name: "with a valid color hex", + state: { + user: mock({ id: "1", isSuperUser: false }), + }, + input: { + userId: "1", + name: faker.company.name(), + colorScheme: "#000", + }, + }, ]; test.each(testCases)( diff --git a/src/services/organizations/members.ts b/src/services/organizations/members.ts index 9139ecef..2698ee17 100644 --- a/src/services/organizations/members.ts +++ b/src/services/organizations/members.ts @@ -7,6 +7,7 @@ import { UnauthorizedError, } from "~/domain/errors.js"; import { + OrganizationMember, OrganizationRole, type OrganizationRoleType, } from "~/domain/organizations.js"; @@ -221,6 +222,57 @@ function buildMembers( const { member: addedMember } = addMemberResult.data; return { ok: true, data: { member: addedMember } }; }, + + async updateRole(ctx, params) { + if (!ctx.user) { + return Result.error( + new UnauthorizedError("You must be logged in to update a role."), + ); + } + + const { memberId, newRole } = params; + try { + const memberToUpdate = await memberRepository.get({ id: memberId }); + if (memberToUpdate.userId === ctx.user.id) { + return Result.error( + new InvalidArgumentErrorV2("You cannot change your own role."), + ); + } + + const { organizationId } = memberToUpdate; + + const isAdmin = await permissions.hasRole(ctx, { + organizationId, + role: OrganizationRole.ADMIN, + }); + if (isAdmin !== true) { + return Result.error( + new PermissionDeniedError( + "You must be an admin of the organization to update a role.", + ), + ); + } + + const result = await memberRepository.updateRole(ctx, { + memberId, + role: newRole, + }); + + if (!result.ok) { + return Result.error(result.error); + } + return Result.success({ + member: new OrganizationMember(result.data.member), + }); + } catch (err) { + if (err instanceof NotFoundError) { + return Result.error(new NotFoundError("Member not found.", err)); + } + return Result.error( + new InternalServerError("Failed to update role.", err), + ); + } + }, }; } export { buildMembers }; diff --git a/src/services/organizations/organizations.ts b/src/services/organizations/organizations.ts index 0ff139c3..da0f86f0 100644 --- a/src/services/organizations/organizations.ts +++ b/src/services/organizations/organizations.ts @@ -6,7 +6,6 @@ import { } from "~/domain/errors.js"; import { FeaturePermission, - type FeaturePermissionType, type Organization, OrganizationRole, type OrganizationRoleType, @@ -85,6 +84,14 @@ function buildOrganizations( .uuid() .nullish() .transform((val) => val ?? undefined), + colorScheme: z + .string() + .regex( + /^#?([a-f0-9]{6}|[a-f0-9]{3})$/, + "Color scheme must be a valid color hex", + ) + .nullish() + .transform((val) => val ?? undefined), }); const { isSuperUser } = ctx.user; @@ -98,13 +105,19 @@ function buildOrganizations( .transform((val) => val ?? undefined), }); - const { name, description, featurePermissions, logoFileId } = - superUserSchema.parse(data); + const { + name, + description, + featurePermissions, + logoFileId, + colorScheme, + } = superUserSchema.parse(data); return await organizationRepository.update(organizationId, { name, description, featurePermissions, logoFileId, + colorScheme, }); } const isMember = await permissions.hasRole(ctx, { @@ -118,12 +131,14 @@ function buildOrganizations( } const schema = baseSchema; - const { name, description, logoFileId } = schema.parse(data); + const { name, description, logoFileId, colorScheme } = + schema.parse(data); return await organizationRepository.update(organizationId, { name, description, logoFileId, + colorScheme, }); } catch (err) { if (err instanceof z.ZodError) @@ -144,14 +159,7 @@ function buildOrganizations( * @param data.featurePermissions - The feature permissions to grant to the user (optional) * @returns The created organization */ - async create( - ctx: Context, - data: { - name: string; - description?: string | null; - featurePermissions?: FeaturePermissionType[] | null; - }, - ): Promise { + async create(ctx, data) { if (!ctx.user) { throw new UnauthorizedError( "You must be logged in to create an organization.", @@ -166,6 +174,14 @@ function buildOrganizations( .max(10000) .nullish() .transform((val) => val ?? undefined), + colorScheme: z + .string() + .regex( + /^#?([a-f0-9]{6}|[a-f0-9]{3})$/, + "Color scheme must be a valid color hex", + ) + .nullish() + .transform((val) => val ?? undefined), }); try { if (isSuperUser === true) { @@ -175,21 +191,24 @@ function buildOrganizations( .nullish() .transform((val) => val ?? undefined), }); - const { name, description, featurePermissions } = schema.parse(data); + const { name, description, featurePermissions, colorScheme } = + schema.parse(data); const organization = await organizationRepository.create({ name, description, userId, featurePermissions, + colorScheme, }); return organization; } const schema = baseSchema; - const { name, description } = schema.parse(data); + const { name, description, colorScheme } = schema.parse(data); const organization = await organizationRepository.create({ name, description, userId, + colorScheme, }); return organization; } catch (err) { diff --git a/src/services/organizations/service.ts b/src/services/organizations/service.ts index 9422ab02..bd8915f4 100644 --- a/src/services/organizations/service.ts +++ b/src/services/organizations/service.ts @@ -23,6 +23,7 @@ interface OrganizationRepository { description?: string; userId: string; featurePermissions?: FeaturePermissionType[]; + colorScheme?: string; }): Promise; update( id: string, @@ -31,6 +32,7 @@ interface OrganizationRepository { description: string; featurePermissions: FeaturePermissionType[]; logoFileId: string; + colorScheme: string; }>, ): Promise; get(id: string): Promise; @@ -70,6 +72,16 @@ interface MemberRepository { organizationId: string; role: OrganizationRoleType; }): Promise; + updateRole( + ctx: Context, + data: { + memberId: string; + role: OrganizationRoleType; + }, + ): ResultAsync< + { member: OrganizationMember }, + InternalServerError | NotFoundError + >; } interface PermissionService {