Skip to main content

Tutorial: Build a Task Manager API

This tutorial builds a task management API with projects, tasks, labels, and due date filtering. It covers many-to-many relationships, query scoping, and bulk operations.

1. Create the Project​

vania create task_manager
cd task_manager
dart pub add vania_mysql vania_auth
dart pub get

Configure .env with your database credentials and run the initial setup as shown in the Blog API tutorial.

2. Create Migrations​

Projects Table​

class CreateProjectsTable extends Migration {
@override
Future<void> up() async {
await create('projects', (Schema schema) {
schema.id();
schema.integer('user_id').unsigned().foreignKey('users', 'id', onDelete: 'CASCADE');
schema.string('name', length: 100);
schema.text('description').nullable();
schema.enumType('status', ['active', 'archived', 'completed']);
schema.timeStamps();
});
}

@override
Future<void> down() async {
await drop('projects');
}
}

Tasks Table​

class CreateTasksTable extends Migration {
@override
Future<void> up() async {
await create('tasks', (Schema schema) {
schema.id();
schema.integer('project_id').unsigned().foreignKey('projects', 'id', onDelete: 'CASCADE');
schema.integer('assigned_to').unsigned().nullable().foreignKey('users', 'id', onDelete: 'SET NULL');
schema.string('title', length: 255);
schema.text('description').nullable();
schema.enumType('priority', ['low', 'medium', 'high', 'urgent']);
schema.enumType('status', ['todo', 'in_progress', 'review', 'done']);
schema.dateTime('due_date').nullable();
schema.dateTime('completed_at').nullable();
schema.integer('sort_order').defaultTo(0);
schema.timeStamps();
schema.softDeletes();
});
}

@override
Future<void> down() async {
await drop('tasks');
}
}

Labels and Pivot Table​

class CreateLabelsTable extends Migration {
@override
Future<void> up() async {
await create('labels', (Schema schema) {
schema.id();
schema.string('name', length: 50);
schema.string('color', length: 7).defaultTo('#6366f1');
});

await create('label_task', (Schema schema) {
schema.id();
schema.integer('label_id').unsigned().foreignKey('labels', 'id', onDelete: 'CASCADE');
schema.integer('task_id').unsigned().foreignKey('tasks', 'id', onDelete: 'CASCADE');
});
}

@override
Future<void> down() async {
await drop('label_task');
await drop('labels');
}
}

3. Create Models​

// lib/app/models/project.dart
import 'package:vania/database.dart';

class Project extends Model {
@override
List<String> get fillable => ['user_id', 'name', 'description', 'status'];

@override
void registerRelations() {
belongsTo('owner', User(), foreignKey: 'user_id');
hasMany('tasks', Task(), foreignKey: 'project_id');
}
}
// lib/app/models/task.dart
import 'package:vania/database.dart';

class Task extends Model {
@override
List<String> get fillable => [
'project_id', 'assigned_to', 'title', 'description',
'priority', 'status', 'due_date', 'completed_at', 'sort_order',
];
@override
bool get softDeletes => true;

@override
void registerRelations() {
belongsTo('project', Project(), foreignKey: 'project_id');
belongsTo('assignee', User(), foreignKey: 'assigned_to');
belongsToMany('labels', Label(), pivotTable: 'label_task',
parentPivotKey: 'task_id', relatedPivotKey: 'label_id');
}
}
// lib/app/models/label.dart
import 'package:vania/database.dart';

class Label extends Model {
@override
List<String> get fillable => ['name', 'color'];
@override
bool get timestamps => false;

@override
void registerRelations() {
belongsToMany('tasks', Task(), pivotTable: 'label_task',
parentPivotKey: 'label_id', relatedPivotKey: 'task_id');
}
}

4. Create Controllers​

Project Controller​

// lib/app/http/controllers/project_controller.dart
import 'package:vania/vania.dart';
import 'package:vania/http/controller.dart';
import 'package:vania/http/request.dart';
import 'package:vania/http/response.dart';
import 'package:task_manager/app/models/project.dart';

class ProjectController extends Controller {
Future<Response> index(Request req) async {
final projects = await Project().query
.where('user_id', '=', req.user?['id'])
.include('tasks')
.orderByDesc('created_at')
.get();
return Response.json(projects);
}

Future<Response> store(Request req) async {
req.validate({
'name': 'required|string|max_length:100',
'description': 'string',
});

final project = await Project().query.create({
'user_id': req.user?['id'],
'name': req.input('name'),
'description': req.input('description'),
'status': 'active',
});

return Response.json(project, 201);
}

Future<Response> show(Request req, int id) async {
final project = await Project().query
.where('user_id', '=', req.user?['id'])
.include('tasks')
.findOrFail(id);
return Response.json(project);
}

Future<Response> update(Request req, int id) async {
req.validate({
'name': 'string|max_length:100',
'status': 'in:active,archived,completed',
});

await Project().query
.where('id', '=', id)
.where('user_id', '=', req.user?['id'])
.update(req.only(['name', 'description', 'status']));

return Response.json({'message': 'Updated'});
}

Future<Response> destroy(Request req, int id) async {
await Project().query
.where('id', '=', id)
.where('user_id', '=', req.user?['id'])
.delete();
return Response.json({'message': 'Deleted'});
}
}

final ProjectController projectController = ProjectController();

Task Controller​

// lib/app/http/controllers/task_controller.dart
import 'package:vania/vania.dart';
import 'package:vania/http/controller.dart';
import 'package:vania/http/request.dart';
import 'package:vania/http/response.dart';
import 'package:vania/database.dart';
import 'package:task_manager/app/models/task.dart';

class TaskController extends Controller {
Future<Response> index(Request req, int projectId) async {
var query = Task().query
.where('project_id', '=', projectId)
.include('assignee:id,name')
.include('labels');

// Filter by status
final status = req.query('status');
if (status != null) {
query = query.where('status', '=', status);
}

// Filter by priority
final priority = req.query('priority');
if (priority != null) {
query = query.where('priority', '=', priority);
}

// Filter overdue tasks
if (req.query('overdue') == 'true') {
query = query
.whereNotNull('due_date')
.where('due_date', '<', DateTime.now().toIso8601String())
.whereNotEqualTo('status', 'done');
}

final tasks = await query.orderBy('sort_order', 'asc').get();
return Response.json(tasks);
}

Future<Response> store(Request req, int projectId) async {
req.validate({
'title': 'required|string|max_length:255',
'priority': 'in:low,medium,high,urgent',
'due_date': 'date',
'assigned_to': 'integer',
});

final task = await Task().query.create({
'project_id': projectId,
'title': req.input('title'),
'description': req.input('description'),
'priority': req.input('priority', 'medium'),
'status': 'todo',
'due_date': req.input('due_date'),
'assigned_to': req.input('assigned_to'),
});

// Attach labels if provided
final labels = req.input('label_ids');
if (labels is List && labels.isNotEmpty) {
for (var labelId in labels) {
await DB.table('label_task').insert({
'task_id': task['id'],
'label_id': labelId,
});
}
}

return Response.json(task, 201);
}

Future<Response> updateStatus(Request req, int projectId, int id) async {
req.validate({'status': 'required|in:todo,in_progress,review,done'});

final updates = <String, dynamic>{'status': req.input('status')};

if (req.input('status') == 'done') {
updates['completed_at'] = DateTime.now().toIso8601String();
}

await Task().query
.where('id', '=', id)
.where('project_id', '=', projectId)
.update(updates);

return Response.json({'message': 'Status updated'});
}

Future<Response> reorder(Request req, int projectId) async {
req.validate({
'tasks': 'required|array',
'tasks.*.id': 'required|integer',
'tasks.*.sort_order': 'required|integer',
});

final tasks = req.input('tasks') as List;
for (var item in tasks) {
await Task().query
.where('id', '=', item['id'])
.where('project_id', '=', projectId)
.update({'sort_order': item['sort_order']});
}

return Response.json({'message': 'Reordered'});
}

Future<Response> destroy(int projectId, int id) async {
await Task().query
.where('id', '=', id)
.where('project_id', '=', projectId)
.delete();
return Response.json({'message': 'Deleted'});
}
}

final TaskController taskController = TaskController();

5. Define Routes​

// lib/route/api_route.dart
class ApiRoute implements Route {
@override
void register() {
Router.basePrefix('api');

Router.post('/register', authController.register);
Router.post('/login', authController.login);

Router.group(() {
// Projects
Router.get('/projects', projectController.index);
Router.post('/projects', projectController.store);
Router.get('/projects/{id}', projectController.show).whereInt('id');
Router.put('/projects/{id}', projectController.update).whereInt('id');
Router.delete('/projects/{id}', projectController.destroy).whereInt('id');

// Tasks (nested under projects)
Router.get('/projects/{projectId}/tasks', taskController.index).whereInt('projectId');
Router.post('/projects/{projectId}/tasks', taskController.store).whereInt('projectId');
Router.patch('/projects/{projectId}/tasks/{id}/status', taskController.updateStatus);
Router.put('/projects/{projectId}/tasks/reorder', taskController.reorder).whereInt('projectId');
Router.delete('/projects/{projectId}/tasks/{id}', taskController.destroy);

// Labels
Router.get('/labels', labelController.index);
Router.post('/labels', labelController.store);
}, middleware: [Authenticate()]);
}
}

6. Test It​

vania serve

Create a project:

curl -X POST http://localhost:8000/api/projects \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"My Project","description":"A sample project"}'

Add a task:

curl -X POST http://localhost:8000/api/projects/1/tasks \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Set up database","priority":"high","due_date":"2026-07-20"}'

Filter overdue tasks:

curl "http://localhost:8000/api/projects/1/tasks?overdue=true" \
-H "Authorization: Bearer YOUR_TOKEN"

Update task status:

curl -X PATCH http://localhost:8000/api/projects/1/tasks/1/status \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"done"}'

This gives you a fully functional task manager with nested resources, query filtering, soft deletes, many-to-many labels, and task reordering.