Sprint 5: User profile menu, preferences, org member management, v0.2.0
- Move Settings from sidebar Admin section to User Profile dropdown menu
- Add User Preferences page (placeholder for future: dark mode, timezone,
notifications, feature visibility)
- Add Manage Members page for tenant admins to invite/manage board members:
- List all org members with roles, status, join date, last login
- Add new members (creates user account + org membership)
- Change member roles (president, treasurer, secretary, board member,
property manager, viewer)
- Remove members (soft-deactivate)
- Role-gated: only president, admin, treasurer can manage members
- Backend: new org member management endpoints on OrganizationsController
- GET /organizations/members
- POST /organizations/members
- PUT /organizations/members/:id/role
- DELETE /organizations/members/:id
- Bump version to 0.2.0 MVP_P2 (package.json + Settings page)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "HOA LedgerIQ - Backend API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Get, Body, UseGuards, Request } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Put, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
@@ -22,4 +22,48 @@ export class OrganizationsController {
|
||||
async findMine(@Request() req: any) {
|
||||
return this.orgService.findByUser(req.user.sub);
|
||||
}
|
||||
|
||||
// ── Org Member Management ──
|
||||
|
||||
private requireTenantAdmin(req: any) {
|
||||
const role = req.user.role;
|
||||
if (!['president', 'admin', 'treasurer'].includes(role) && !req.user.isSuperadmin) {
|
||||
throw new ForbiddenException('Only organization administrators can manage members');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('members')
|
||||
@ApiOperation({ summary: 'List members of current organization' })
|
||||
async getMembers(@Request() req: any) {
|
||||
this.requireTenantAdmin(req);
|
||||
return this.orgService.getMembers(req.user.orgId);
|
||||
}
|
||||
|
||||
@Post('members')
|
||||
@ApiOperation({ summary: 'Add a member to the current organization' })
|
||||
async addMember(
|
||||
@Request() req: any,
|
||||
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
|
||||
) {
|
||||
this.requireTenantAdmin(req);
|
||||
return this.orgService.addMember(req.user.orgId, body);
|
||||
}
|
||||
|
||||
@Put('members/:id/role')
|
||||
@ApiOperation({ summary: 'Update a member role' })
|
||||
async updateMemberRole(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { role: string },
|
||||
) {
|
||||
this.requireTenantAdmin(req);
|
||||
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
|
||||
}
|
||||
|
||||
@Delete('members/:id')
|
||||
@ApiOperation({ summary: 'Remove a member from the organization' })
|
||||
async removeMember(@Request() req: any, @Param('id') id: string) {
|
||||
this.requireTenantAdmin(req);
|
||||
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Injectable, ConflictException } from '@nestjs/common';
|
||||
import { Injectable, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Organization } from './entities/organization.entity';
|
||||
import { UserOrganization } from './entities/user-organization.entity';
|
||||
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsService {
|
||||
@@ -76,6 +77,105 @@ export class OrganizationsService {
|
||||
return this.orgRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
// ── Org Member Management ──
|
||||
|
||||
async getMembers(orgId: string) {
|
||||
const dataSource = this.orgRepository.manager.connection;
|
||||
const rows = await dataSource.query(
|
||||
`SELECT
|
||||
uo.id,
|
||||
uo.user_id as "userId",
|
||||
u.email,
|
||||
u.first_name as "firstName",
|
||||
u.last_name as "lastName",
|
||||
uo.role,
|
||||
uo.is_active as "isActive",
|
||||
uo.joined_at as "joinedAt",
|
||||
u.last_login_at as "lastLoginAt"
|
||||
FROM shared.user_organizations uo
|
||||
JOIN shared.users u ON u.id = uo.user_id
|
||||
WHERE uo.organization_id = $1
|
||||
ORDER BY uo.joined_at ASC`,
|
||||
[orgId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async addMember(
|
||||
orgId: string,
|
||||
data: { email: string; firstName: string; lastName: string; password: string; role: string },
|
||||
) {
|
||||
const dataSource = this.orgRepository.manager.connection;
|
||||
|
||||
// Check if user already exists
|
||||
let userRows = await dataSource.query(
|
||||
`SELECT id FROM shared.users WHERE email = $1`,
|
||||
[data.email.toLowerCase()],
|
||||
);
|
||||
|
||||
let userId: string;
|
||||
|
||||
if (userRows.length > 0) {
|
||||
userId = userRows[0].id;
|
||||
// Check if already a member of this org
|
||||
const existing = await this.userOrgRepository.findOne({
|
||||
where: { userId, organizationId: orgId },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.isActive) {
|
||||
throw new ConflictException('User is already a member of this organization');
|
||||
}
|
||||
// Re-activate an existing inactive membership
|
||||
existing.isActive = true;
|
||||
existing.role = data.role;
|
||||
return this.userOrgRepository.save(existing);
|
||||
}
|
||||
} else {
|
||||
// Create new user
|
||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||
const result = await dataSource.query(
|
||||
`INSERT INTO shared.users (email, password_hash, first_name, last_name)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id`,
|
||||
[data.email.toLowerCase(), passwordHash, data.firstName, data.lastName],
|
||||
);
|
||||
userId = result[0].id;
|
||||
}
|
||||
|
||||
// Create membership
|
||||
const membership = this.userOrgRepository.create({
|
||||
userId,
|
||||
organizationId: orgId,
|
||||
role: data.role,
|
||||
});
|
||||
return this.userOrgRepository.save(membership);
|
||||
}
|
||||
|
||||
async updateMemberRole(orgId: string, membershipId: string, role: string) {
|
||||
const membership = await this.userOrgRepository.findOne({
|
||||
where: { id: membershipId, organizationId: orgId },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new NotFoundException('Membership not found');
|
||||
}
|
||||
membership.role = role;
|
||||
return this.userOrgRepository.save(membership);
|
||||
}
|
||||
|
||||
async removeMember(orgId: string, membershipId: string, requestingUserId: string) {
|
||||
const membership = await this.userOrgRepository.findOne({
|
||||
where: { id: membershipId, organizationId: orgId },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new NotFoundException('Membership not found');
|
||||
}
|
||||
if (membership.userId === requestingUserId) {
|
||||
throw new BadRequestException('You cannot remove yourself from the organization');
|
||||
}
|
||||
membership.isActive = false;
|
||||
return this.userOrgRepository.save(membership);
|
||||
}
|
||||
|
||||
private generateSchemaName(name: string): string {
|
||||
const clean = name
|
||||
.toLowerCase()
|
||||
|
||||
Reference in New Issue
Block a user