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_KEYSinserver/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 upgradeThe 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'}}), 2006. 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.pyAdmin-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
tenantIdin its query key and be gated on!!tenantIdviaenabled. Prefer theuseTenantQuery/useTenantInfiniteQuerywrappers inadmin-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 AnnouncementsScreenThen 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(extendsTenantBaseModel) - Migration created and applied (
flask db migrate+flask db upgrade) - Service layer in
services.py(no DB queries in routes) - Routes in
routes.pywith 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 +enabledgate) - 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
| Mistake | Fix |
|---|---|
No tenant_id filter in query | Always filter by g.tenant_id in services |
Missing @auth_required on route | Add all required decorators |
SELECT * queries | Explicit .to_dict() with only needed fields |
| Business logic in route handler | Move it to services.py |
| Direct DB access from a Next.js page | Use the service + hook pattern |
| Hardcoded permission string in frontend | Import from permissions.ts constants |
| Not invalidating React Query cache after mutation | Add onSuccess: () => queryClient.invalidateQueries(...) |
Query key missing tenantId | Include tenantId in the key (or use useTenantQuery) |
| Mobile tab visible without a permission check | Add requiredPermissions to the navigation config |