EngineeringAdding a feature

Adding a feature

An end-to-end walkthrough for adding a complete feature to NexSchool — from database model to admin-web, mobile, tests, and docs — following the project’s architecture and conventions. The running example is an Announcements module.

A full feature touches five layers: database (model + migration), backend (service + routes + blueprint), RBAC (permissions + role assignments), admin-web (service + hook + page + nav), and mobile (service + screen + navigation). Work top-down: schema first, then service, then the API, then the clients.

Backend

1. Plan the feature

Before writing code, decide:

  • Is this plan-gated? If so, add the feature key to PLAN_FEATURE_KEYS in server/core/plan_features.py.
  • What tables are needed?
  • What permissions?
  • What API routes?
# server/core/plan_features.py
PLAN_FEATURE_KEYS = [
    # existing...
    'announcements',  # ADD THIS
]

2. Create the model

Models live in server/modules/<name>/models.py and extend TenantBaseModel, which supplies id, tenant_id, created_at, and updated_at.

# server/modules/announcements/models.py
from core.database import db
from core.models import TenantBaseModel
from sqlalchemy import Column, Integer, Text, Boolean, DateTime
from sqlalchemy.sql import func
 
class Announcement(TenantBaseModel):
    __tablename__ = 'announcements'
 
    title = Column(Text, nullable=False)
    body = Column(Text, nullable=False)
    is_pinned = Column(Boolean, default=False)
    published_at = Column(DateTime(timezone=True))
    expires_at = Column(DateTime(timezone=True))
    created_by = Column(Integer, db.ForeignKey('users.id'), nullable=False)
    # tenant_id, id, created_at, updated_at inherited from TenantBaseModel
 
    def to_dict(self):
        return {
            'id': self.id,
            'title': self.title,
            'body': self.body,
            'is_pinned': self.is_pinned,
            'published_at': self.published_at.isoformat() if self.published_at else None,
            'expires_at': self.expires_at.isoformat() if self.expires_at else None,
            'created_by': self.created_by,
            'created_at': self.created_at.isoformat(),
        }

3. Create and apply the migration

Run from the server/ directory (or inside the Docker container):

flask db migrate -m "add announcements table"
 
# Review the generated migration in server/migrations/versions/,
# confirm it looks correct, then apply:
flask db upgrade

The generated migration should resemble:

def upgrade():
    op.create_table(
        'announcements',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id'), nullable=False),
        sa.Column('title', sa.Text(), nullable=False),
        sa.Column('body', sa.Text(), nullable=False),
        sa.Column('is_pinned', sa.Boolean(), default=False),
        sa.Column('published_at', sa.DateTime(timezone=True)),
        sa.Column('expires_at', sa.DateTime(timezone=True)),
        sa.Column('created_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
        sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
        sa.Column('updated_at', sa.DateTime(timezone=True)),
        sa.PrimaryKeyConstraint('id')
    )
    op.create_index('idx_announcements_tenant', 'announcements', ['tenant_id'])
 
def downgrade():
    op.drop_table('announcements')

4. Create the service layer

All database access lives in server/modules/<name>/services.py — never query the DB from a route. Every query must filter by g.tenant_id.

# server/modules/announcements/services.py
from flask import g
from core.database import db
from .models import Announcement
 
def get_announcements(page=1, page_size=20, pinned_only=False):
    query = Announcement.query.filter_by(tenant_id=g.tenant_id)
    if pinned_only:
        query = query.filter_by(is_pinned=True)
    query = query.order_by(Announcement.is_pinned.desc(), Announcement.created_at.desc())
    total = query.count()
    items = query.offset((page - 1) * page_size).limit(page_size).all()
    return items, total
 
def get_announcement(announcement_id):
    return Announcement.query.filter_by(
        id=announcement_id,
        tenant_id=g.tenant_id
    ).first_or_404()
 
def create_announcement(data, user_id):
    announcement = Announcement(
        tenant_id=g.tenant_id,
        title=data['title'],
        body=data['body'],
        is_pinned=data.get('is_pinned', False),
        published_at=data.get('published_at'),
        expires_at=data.get('expires_at'),
        created_by=user_id,
    )
    db.session.add(announcement)
    db.session.commit()
    return announcement
 
def update_announcement(announcement_id, data):
    announcement = get_announcement(announcement_id)
    for field in ['title', 'body', 'is_pinned', 'published_at', 'expires_at']:
        if field in data:
            setattr(announcement, field, data[field])
    db.session.commit()
    return announcement
 
def delete_announcement(announcement_id):
    announcement = get_announcement(announcement_id)
    db.session.delete(announcement)
    db.session.commit()

5. Create the routes

Routes live in server/modules/<name>/routes.py. Each handler stacks the required decorators: @auth_required, @tenant_required, @require_permission(...), and — where the feature is plan-gated — @require_plan_feature(...). Handlers stay thin: validate, delegate to the service, shape the response.

# server/modules/announcements/routes.py
from flask import Blueprint, request, jsonify, g
from core.decorators.auth import auth_required, tenant_required, require_permission
from core.feature_flags import require_plan_feature
from . import services
 
announcements_bp = Blueprint('announcements', __name__)
 
@announcements_bp.route('/', methods=['GET'])
@auth_required
@tenant_required
@require_permission('announcement.read')
@require_plan_feature('announcements')
def list_announcements():
    page = request.args.get('page', 1, type=int)
    page_size = min(request.args.get('pageSize', 20, type=int), 100)
    pinned_only = request.args.get('pinned_only', False, type=bool)
 
    items, total = services.get_announcements(page, page_size, pinned_only)
    return jsonify({
        'success': True,
        'data': [a.to_dict() for a in items],
        'pagination': {
            'page': page,
            'pageSize': page_size,
            'totalItems': total,
            'totalPages': -(-total // page_size),
        }
    })
 
@announcements_bp.route('/', methods=['POST'])
@auth_required
@tenant_required
@require_permission('announcement.create')
@require_plan_feature('announcements')
def create_announcement():
    data = request.get_json()
    if not data or not data.get('title') or not data.get('body'):
        return jsonify({'success': False, 'message': 'title and body are required'}), 400
 
    announcement = services.create_announcement(data, g.current_user.id)
    return jsonify({'success': True, 'data': announcement.to_dict()}), 201
 
@announcements_bp.route('/<int:announcement_id>', methods=['GET'])
@auth_required
@tenant_required
@require_permission('announcement.read')
def get_announcement(announcement_id):
    announcement = services.get_announcement(announcement_id)
    return jsonify({'success': True, 'data': announcement.to_dict()})
 
@announcements_bp.route('/<int:announcement_id>', methods=['PUT'])
@auth_required
@tenant_required
@require_permission('announcement.update')
def update_announcement(announcement_id):
    data = request.get_json()
    announcement = services.update_announcement(announcement_id, data)
    return jsonify({'success': True, 'data': announcement.to_dict()})
 
@announcements_bp.route('/<int:announcement_id>', methods=['DELETE'])
@auth_required
@tenant_required
@require_permission('announcement.delete')
def delete_announcement(announcement_id):
    services.delete_announcement(announcement_id)
    return jsonify({'success': True, 'data': {'message': 'Deleted'}}), 200

6. Register the blueprint

Add the blueprint to register_blueprints() in server/app.py, mounting it under /api/<name>.

# server/app.py
def register_blueprints(app):
    # ... existing blueprints ...
    from server.modules.announcements.routes import announcements_bp
    app.register_blueprint(announcements_bp, url_prefix='/api/announcements')

7. Add RBAC permissions

Declare permissions and assign them to roles in server/scripts/seed_rbac.py.

# Add to PERMISSIONS list:
PERMISSIONS = [
    # ... existing ...
    {'resource': 'announcement', 'action': 'read', 'description': 'View announcements'},
    {'resource': 'announcement', 'action': 'create', 'description': 'Create announcements'},
    {'resource': 'announcement', 'action': 'update', 'description': 'Edit announcements'},
    {'resource': 'announcement', 'action': 'delete', 'description': 'Delete announcements'},
]
 
# Assign to roles:
ROLE_PERMISSIONS = {
    'admin': ['announcement.read', 'announcement.create', 'announcement.update', 'announcement.delete'],
    'teacher': ['announcement.read'],
    'student': ['announcement.read'],
}

Run the seed script:

docker compose exec api python scripts/seed_rbac.py

Admin-web

8. Service

The typed API client lives in admin-web/src/services/<name>Service.ts.

// admin-web/src/services/announcementsService.ts
import api from './api'
 
export interface Announcement {
  id: number
  title: string
  body: string
  is_pinned: boolean
  published_at: string | null
  created_at: string
}
 
export interface AnnouncementCreatePayload {
  title: string
  body: string
  is_pinned?: boolean
  published_at?: string
  expires_at?: string
}
 
export const announcementsService = {
  getAnnouncements: async (params?: { page?: number; pageSize?: number }) => {
    const response = await api.get('/announcements', { params })
    return response.data
  },
 
  createAnnouncement: async (payload: AnnouncementCreatePayload) => {
    const response = await api.post('/announcements', payload)
    return response.data.data as Announcement
  },
 
  updateAnnouncement: async (id: number, payload: Partial<AnnouncementCreatePayload>) => {
    const response = await api.put(`/announcements/${id}`, payload)
    return response.data.data as Announcement
  },
 
  deleteAnnouncement: async (id: number) => {
    await api.delete(`/announcements/${id}`)
  },
}

9. React Query hook

Hooks live in admin-web/src/hooks/use<Name>.ts. Invalidate the list cache on every mutation.

// admin-web/src/hooks/useAnnouncements.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { announcementsService } from '@/services/announcementsService'
 
export function useAnnouncements(params?: { page?: number; pageSize?: number }) {
  return useQuery({
    queryKey: ['announcements', params],
    queryFn: () => announcementsService.getAnnouncements(params),
  })
}
 
export function useCreateAnnouncement() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: announcementsService.createAnnouncement,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['announcements'] })
    },
  })
}
 
export function useDeleteAnnouncement() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: announcementsService.deleteAnnouncement,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['announcements'] })
    },
  })
}

Tenant-scoping reminder. Every tenant-scoped query must include tenantId in its query key and be gated on !!tenantId via enabled. Prefer the useTenantQuery / useTenantInfiniteQuery wrappers in admin-web/src/hooks/useTenantQuery.ts, which handle both automatically. See .claude/rules/query-conventions.md. The example above is simplified — do not ship an un-scoped key for a tenant endpoint.

10. Page

Pages live under admin-web/src/app/(dashboard)/<name>/page.tsx. Gate rendering and actions on permissions via hasPermission(...) from the auth provider.

// admin-web/src/app/(dashboard)/announcements/page.tsx
'use client'
import { useAnnouncements, useDeleteAnnouncement } from '@/hooks/useAnnouncements'
import { useAuth } from '@/providers/AuthProvider'
import { Button } from '@/components/ui/button'
import { useState } from 'react'
import CreateAnnouncementModal from '@/components/announcements/CreateAnnouncementModal'
 
export default function AnnouncementsPage() {
  const { hasPermission } = useAuth()
  const { data, isLoading } = useAnnouncements()
  const deleteAnnouncement = useDeleteAnnouncement()
  const [showCreate, setShowCreate] = useState(false)
 
  if (!hasPermission('announcement.read')) return <div>Access denied</div>
 
  return (
    <div>
      <div className="flex justify-between items-center mb-4">
        <h1>Announcements</h1>
        {hasPermission('announcement.create') && (
          <Button onClick={() => setShowCreate(true)}>New Announcement</Button>
        )}
      </div>
 
      {isLoading ? <div>Loading...</div> : (
        <ul>
          {data?.data.map(announcement => (
            <li key={announcement.id}>
              <h3>{announcement.title}</h3>
              <p>{announcement.body}</p>
              {hasPermission('announcement.delete') && (
                <Button
                  variant="destructive"
                  onClick={() => deleteAnnouncement.mutate(announcement.id)}
                >
                  Delete
                </Button>
              )}
            </li>
          ))}
        </ul>
      )}
 
      {showCreate && (
        <CreateAnnouncementModal onClose={() => setShowCreate(false)} />
      )}
    </div>
  )
}

11. Sidebar navigation

Add the nav entry in admin-web/src/components/layout/Sidebar.tsx, gated on both the plan feature and the read permission.

{isFeatureEnabled('announcements') && hasPermission('announcement.read') && (
  <NavItem href="/announcements" icon={<MegaphoneIcon />}>
    Announcements
  </NavItem>
)}

Mobile

12. Service

Mobile services live in client/modules/<name>/services/<name>Service.ts and use the shared API helpers.

// client/modules/announcements/services/announcementsService.ts
import { apiGet, apiPost, apiPut, apiDelete } from '@/common/services/api'
 
export const announcementsService = {
  getAnnouncements: (params?: object) =>
    apiGet<{ data: Announcement[]; pagination: Pagination }>('/api/announcements', params),
 
  createAnnouncement: (payload: AnnouncementCreatePayload) =>
    apiPost<Announcement>('/api/announcements', payload),
}

13. Screen

Screens live in client/modules/<name>/screens/<Name>Screen.tsx.

// client/modules/announcements/screens/AnnouncementsScreen.tsx
import { useQuery } from '@tanstack/react-query'
import { announcementsService } from '../services/announcementsService'
import { FlatList, Text, View } from 'react-native'
 
export function AnnouncementsScreen() {
  const { data, isLoading } = useQuery({
    queryKey: ['announcements'],
    queryFn: () => announcementsService.getAnnouncements(),
  })
 
  return (
    <FlatList
      data={data?.data}
      keyExtractor={item => String(item.id)}
      renderItem={({ item }) => (
        <View>
          <Text style={{ fontWeight: 'bold' }}>{item.title}</Text>
          <Text>{item.body}</Text>
        </View>
      )}
    />
  )
}

14. Navigation

Add the route file at client/app/(protected)/<name>.tsx:

// client/app/(protected)/announcements.tsx
import { AnnouncementsScreen } from '@/modules/announcements/screens/AnnouncementsScreen'
export default AnnouncementsScreen

Then add the tab entry in client/common/constants/navigation.ts. Include requiredPermissions and requiredPlanFeature so the tab is hidden when the user lacks access or the tenant’s plan omits the feature.

{
  name: 'announcements',
  label: 'Announcements',
  icon: 'megaphone',
  href: '/announcements',
  requiredPermissions: ['announcement.read'],
  requiredPlanFeature: 'announcements',
}

Tests

Write tests for the service layer — it holds the business logic and the tenant-scoping. Cover at least the happy path, one error/authorization case, and any non-trivial query behavior (pagination, pinned_only filter). See .claude/rules/testing-conventions.md for structure and mocking rules.

Docs

After the feature lands, add its endpoints to the API contract doc at docs-new/09-api-contracts.md, and update the matching module doc under docs-new/ with a minimal diff.

Checklist

  • Feature key added to PLAN_FEATURE_KEYS (if plan-gated)
  • Model created in server/modules/<name>/models.py (extends TenantBaseModel)
  • Migration created and applied (flask db migrate + flask db upgrade)
  • Service layer in services.py (no DB queries in routes)
  • Routes in routes.py with all required decorators
  • Blueprint registered in server/app.py
  • RBAC permissions seeded in scripts/seed_rbac.py
  • Admin-web service in admin-web/src/services/
  • Admin-web React Query hook in admin-web/src/hooks/ (tenant-scoped key + enabled gate)
  • Admin-web page in admin-web/src/app/(dashboard)/<name>/page.tsx
  • Admin-web sidebar nav entry (feature-gated + permission-gated)
  • Mobile service in client/modules/<name>/services/
  • Mobile screen in client/modules/<name>/screens/
  • Mobile route file in client/app/(protected)/<name>.tsx
  • Mobile tab entry in client/common/constants/navigation.ts
  • Tests written for the service layer
  • API contract added to docs-new/09-api-contracts.md

Common mistakes

MistakeFix
No tenant_id filter in queryAlways filter by g.tenant_id in services
Missing @auth_required on routeAdd all required decorators
SELECT * queriesExplicit .to_dict() with only needed fields
Business logic in route handlerMove it to services.py
Direct DB access from a Next.js pageUse the service + hook pattern
Hardcoded permission string in frontendImport from permissions.ts constants
Not invalidating React Query cache after mutationAdd onSuccess: () => queryClient.invalidateQueries(...)
Query key missing tenantIdInclude tenantId in the key (or use useTenantQuery)
Mobile tab visible without a permission checkAdd requiredPermissions to the navigation config