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:
2026-02-22 16:39:17 -05:00
parent b5861de609
commit ea49b91bb3
10 changed files with 756 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-backend",
"version": "0.1.0",
"version": "0.2.0",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {

View File

@@ -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);
}
}

View File

@@ -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()