feat(tasking): add projects feature and restructure backend

- Restructure backend code into Tasking module with organized endpoints
  - Add Project and Sprint entities with database migrations
  - Implement CRUD endpoints for projects (create, get, rename, delete)
  - Refactor task endpoints into Tasking namespace
  - Add integration test suite with Testcontainers and Respawn
  - Refactor frontend to use Pinia stores with dedicated API clients
  - Add DueDatePicker and DueTimePicker components for task scheduling
  - Add environment configuration for API base URL
  - Add infrastructure setup scripts for Docker/Postgres
This commit is contained in:
2025-12-16 15:33:35 -05:00
parent c973237053
commit 5c93d81ad5
67 changed files with 5971 additions and 1653 deletions

6
Tasker.Ui/.env Normal file
View File

@@ -0,0 +1,6 @@
# Development environment variables for Vite
# You can override these locally without committing secrets.
# Vite only exposes variables prefixed with VITE_.
# Backend API base URL for development
VITE_API_BASE_URL=http://localhost:5239/

View File

@@ -0,0 +1,5 @@
# Production environment variables for Vite
# Replace the URL below with your production API endpoint.
# Vite only exposes variables prefixed with VITE_.
VITE_API_BASE_URL=https://api.example.com/

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to Vuetify 3</title> <title>Done By Daylight</title>
</head> </head>
<body> <body>

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,8 @@
"dependencies": { "dependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"@vueuse/core": "^10.11.0", "@vueuse/core": "^10.11.0",
"axios": "^1.6.7", "axios": "^1.7.7",
"date-fns": "^3.6.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"uuidv7": "^1.0.1", "uuidv7": "^1.0.1",
"vue": "^3.4.29", "vue": "^3.4.29",

View File

@@ -36,6 +36,9 @@
value="users" value="users"
></v-list-item> ></v-list-item>
</v-list> </v-list>
<project-section></project-section>
</v-navigation-drawer> </v-navigation-drawer>
<v-app-bar app <v-app-bar app
@@ -67,31 +70,14 @@
</v-app> </v-app>
</template> </template>
m
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import ProjectSection from '@/components/ProjectSection.vue'
const drawer = ref(true) const drawer = ref(true)
const rail = ref(false) const rail = ref(false)
const group = ref(null) const group = ref(null)
const items = ref([
{
title: 'Foo',
value: 'foo'
},
{
title: 'Bar',
value: 'bar'
},
{
title: 'Fizz',
value: 'fizz'
},
{
title: 'Buzz',
value: 'buzz'
}
])
watch(group, () => { watch(group, () => {
drawer.value = false drawer.value = false

View File

@@ -1,120 +0,0 @@
import { ref, onMounted } from 'vue'
export interface Task {
id: string;
name: string; // [MaxLength(255)]
description?: string | null; // [MaxLength(2048)]
dueDate?: string | null;
}
export interface TaskApiClient {
getTasks(): Promise<Task[]>;
addTask(task: Task): Promise<Task>;
}
export class TaskApiClientImpl implements TaskApiClient {
private readonly baseUrl: string
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
async getTasks(): Promise<Task[]> {
const response = await fetch(`${this.baseUrl}/tasks`)
if (!response.ok) {
throw new Error('Failed to fetch tasks')
}
const data = await response.json()
return data as Task[]
}
async addTask(task: Task): Promise<Task> {
const response = await fetch(`${this.baseUrl}/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(task)
})
if (!response.ok) {
throw new Error('Failed to add task')
}
const data = await response.json()
return data as Task
}
async completeTask(id: string): Promise<Task> {
const response = await fetch(`${this.baseUrl}/tasks/${id}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
'CompleteOn': new Date().toISOString()
})
})
if (!response.ok) {
throw new Error('Failed to add task')
}
const data = await response.json()
return data as Task
}
}
export function useTasks() {
const tasks = ref<Task[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const taskApiClient = new TaskApiClientImpl('https://localhost:7055')
const fetchTasks = async () => {
isLoading.value = true
error.value = null
try {
tasks.value = await taskApiClient.getTasks()
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
const addTask = async (task: Task) => {
isLoading.value = true
error.value = null
try {
const newTask = await taskApiClient.addTask(task)
tasks.value.push(newTask)
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
const completeTask = async (id: string) => {
isLoading.value = true
error.value = null
try {
await taskApiClient.completeTask(id)
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
onMounted(fetchTasks)
return {
tasks,
isLoading,
error,
fetchTasks,
addTask,
completeTask
}
}

View File

@@ -0,0 +1,160 @@
<template>
<div class="due-date-picker">
<v-card>
<!-- Date Search Input -->
<v-text-field
v-model="searchQuery"
label="Type a due date"
variant="underlined"
></v-text-field>
<v-divider></v-divider>
<!-- Quick Date Selection -->
<v-list>
<v-list-item @click="selectQuickDate('today')">
<v-icon left>mdi-calendar-today</v-icon>
<v-list-item-title>Today</v-list-item-title>
<v-list-item-subtitle>{{ formattedToday }}</v-list-item-subtitle>
</v-list-item>
<v-list-item @click="selectQuickDate('tomorrow')">
<v-icon left>mdi-calendar-arrow-right</v-icon>
<v-list-item-title>Tomorrow</v-list-item-title>
<v-list-item-subtitle>{{ formattedTomorrow }}</v-list-item-subtitle>
</v-list-item>
<v-list-item @click="selectQuickDate('weekend')">
<v-icon left>mdi-calendar-weekend</v-icon>
<v-list-item-title>This weekend</v-list-item-title>
<v-list-item-subtitle>{{ formattedWeekend }}</v-list-item-subtitle>
</v-list-item>
<v-list-item @click="selectQuickDate('nextWeek')">
<v-icon left>mdi-calendar-arrow-right-outline</v-icon>
<v-list-item-title>Next week</v-list-item-title>
<v-list-item-subtitle>{{ formattedNextWeek }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-divider></v-divider>
<!-- Date Picker -->
<v-date-picker v-model="selectedDate"></v-date-picker>
<v-btn @click="openTimePicker">Time</v-btn>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
text="Cancel"
color="secondary"
@click="cancel"
></v-btn>
<v-btn
text="Set Time"
color="primary"
@click="save"
></v-btn>
</v-card-actions>
</v-card>
<!-- DueTimePicker Component -->
<due-time-picker
v-model="isTimeDialogOpen"
:time="selectedTime"
@cancel=""
@save="handleTimeSave"
/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, defineEmits} from 'vue'
import {format, addDays, nextMonday} from 'date-fns'
import DueTimePicker from './DueTimePicker.vue'
import type { Time } from './Time';
// Reactive state
const selectedDate = ref<Date | null>(null)
const searchQuery = ref<string>('')
const selectedTime = ref<Time | null>(null)
interface DueDateChanged {
selectedDate: Date,
selectedTime: Time,
}
const emit = defineEmits<{
change: [value: DueDateChanged],
update: [value: number]
}>()
// Dialog state for time picker
const isTimeDialogOpen = ref<boolean>(false)
// Methods to get quick dates
const today = new Date()
const tomorrow = addDays(today, 1)
const weekend = addDays(today, 6 - today.getDay())
const nextWeek = nextMonday(today)
// Computed formatted dates
const formattedToday = computed(() => format(today, 'PPPP'))
const formattedTomorrow = computed(() => format(tomorrow, 'PPPP'))
const formattedWeekend = computed(() => format(weekend, 'PPPP'))
const formattedNextWeek = computed(() => format(nextWeek, 'PPPP'))
// Select quick date options
function selectQuickDate(option: 'today' | 'tomorrow' | 'weekend' | 'nextWeek') {
switch (option) {
case 'today':
selectedDate.value = today
break
case 'tomorrow':
selectedDate.value = tomorrow
break
case 'weekend':
selectedDate.value = weekend
break
case 'nextWeek':
selectedDate.value = nextWeek
break
}
}
function save() {
emit('update', 1)
}
function cancel() {
}
// Open Time Picker Dialog
function openTimePicker() {
isTimeDialogOpen.value = true
}
// Handle Save Event from DueTimePicker
function handleTimeSave(time: Time) {
console.log('Time:', time.time)
console.log('Duration:', time.duration)
console.log('Time Zone:', time.timeZone)
}
</script>
<style scoped>
.due-date-picker {
width: 350px;
}
.v-card {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<v-dialog v-model="isTimeDialogOpen" width="400px">
<v-card>
<v-card-title>Time Picker</v-card-title>
<v-card-text>
<!-- Time Picker or Manual Input -->
<v-autocomplete
v-model="selectedTime"
:items="timeOptions"
label="Select or Enter Time"
clearable
@blur="validateTime"
></v-autocomplete>
<!-- Duration Picker -->
<v-select
v-model="selectedDuration"
:items="durations"
label="Duration"
></v-select>
<!-- Time Zone Picker -->
<v-select
v-model="selectedTimeZone"
:items="timeZones"
label="Time zone"
></v-select>
</v-card-text>
<v-card-actions>
<v-btn color="secondary" @click="close">Cancel</v-btn>
<v-btn color="primary" @click="saveTimeSettings">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import {ref} from 'vue';
import {type Time} from './Time';
const emit = defineEmits<{
cancel: [],
ok: [value: Time]
}>()
// Reactive state
const selectedTime = ref<string | null>(null);
const selectedDuration = ref<string | null>(null);
const selectedTimeZone = ref<string | null>('Floating time');
// Duration and Time Zones options
const durations = ref(['No duration', '15 mins', '30 mins', '1 hour', '2 hours']);
const timeZones = ref(['Floating time', 'Pacific Time', 'Mountain Time', 'Central Time', 'Eastern Time']);
// Time options every 15 minutes
const timeOptions = ref(generateTimeOptions());
// Generate time options in 15-minute intervals
function generateTimeOptions() {
const times = [];
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const time = new Date(0, 0, 0, hour, minute).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
times.push(time);
}
}
return times;
}
// Validate manually entered time
function validateTime() {
if (!selectedTime.value) return;
const isValidTime = timeOptions.value.includes(selectedTime.value);
if (!isValidTime) {
// If the time is not valid, you might want to reset it or show an error
console.log('Invalid time entered');
}
}
// Close the dialog
function close() {
emit('cancel', false);
}
// Save Time, Duration, and Time Zone Settings
function saveTimeSettings() {
emit('ok', {
time: selectedTime.value,
duration: selectedDuration.value,
timeZone: selectedTimeZone.value,
});
close();
}
</script>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue'
const showForm = ref(false)
const name = ref('')
const description = ref(null)
function createProject() {
console.log(`Create new project: ${name.value} - ${description.value}`)
}
</script>
<template>
<v-btn v-if="!showForm" @click="showForm = true">
<v-icon left>mdi-plus</v-icon>
Add New Project
</v-btn>
<v-form v-else>
<v-card>
<v-text-field
label="Name"
v-model="name"
required
></v-text-field>
<v-text-field
label="Description"
v-model="description"
></v-text-field>
<v-card-actions>
<v-btn color="success" @click="createProject">
Submit
</v-btn>
<v-btn color="error" @click="showForm = false">
Cancel
</v-btn>
</v-card-actions>
</v-card>
</v-form>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useProjectStore } from '@/stores/projectStore'
const projectStore = useProjectStore()
</script>
<template>
<v-list density="compact" nav>
<v-list-item
v-for="(item, index) in projectStore.projects"
:key="index"
>
<v-list-item-title>{{ item.name }}</v-list-item-title>
<v-list-item-subtitle>{{ item.description }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import ProjectList from '@/components/ProjectList.vue'
import ProjectCreate from '@/components/ProjectCreate.vue'
</script>
<template>
<h1>Projects</h1>
<project-list></project-list>
<project-create></project-create>
</template>
<style scoped>
</style>

View File

@@ -1,26 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { type Task, useTasks } from '@/api/taskApi'
import { uuidv7 } from 'uuidv7' import { uuidv7 } from 'uuidv7'
import DueDatePicker from '@/components/DueDatePicker.vue'
import { useTaskStore } from '@/stores/taskStore'
const name = ref('') const name = ref<string>('')
const description = ref(null) const description = ref<string | null>(null)
const dueDate = ref(null) const dueDate = ref<string | null>(null)
const { addTask } = useTasks() const modalDueDatePicker = ref<boolean>(false)
const doAddTask = () => { function changeDueDate(): void {
const newTask: Task = { modalDueDatePicker.value = !modalDueDatePicker.value
}
function onDueDateChanged(value: string): void {
dueDate.value = value
}
const taskStore = useTaskStore()
function doAddTask(): Task {
taskStore.addTask({
id: uuidv7(), id: uuidv7(),
name: name.value, name: name.value,
description: description.value, description: description.value,
dueDate: dueDate.value dueDate: dueDate.value
} } as Task)
addTask(newTask)
} }
const cancelAddTask = () => { const cancelAddTask = () => {
} }
</script> </script>
@@ -38,19 +48,28 @@ const cancelAddTask = () => {
</v-text-field> </v-text-field>
<div class="flex flex-row space-x-2"> <div class="flex flex-row space-x-2">
<span>due-date</span> <v-btn @click="changeDueDate()">
<span>due-date</span>
</v-btn>
<v-dialog v-model="modalDueDatePicker"
width="400px">
<due-date-picker :value="dueDate"
@value-changed="onDueDateChanged"
></due-date-picker>
</v-dialog>
<span>priority</span> <span>priority</span>
<span>tags</span> <span>tags</span>
<span>reminders</span> <span>reminders</span>
</div> </div>
<div class=" bg-teal-400 p-2 flex flex-row space-x-2 justify-end"> <div class="bg-teal-400 p-2 flex flex-row space-x-2 justify-end">
<span class="justify-start">projects</span> <span class="justify-start">projects</span>
<div class="bg-red space-x-2 justify-end"> <div class="bg-red space-x-2 justify-end">
<v-btn @click="cancelAddTask()" <v-btn @click="cancelAddTask()"
variant="outlined"> variant="outlined">

View File

@@ -1,44 +1,45 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTaskStore } from '@/stores/taskStore'
import type { Task } from '@/stores/task'
import { useTasks } from '@/api/taskApi' const taskStore = useTaskStore()
const { completeTask, tasks, isLoading, error } = useTasks() function toggleTaskComplete(task: Task) {
taskStore.completeTask(task) // Call the API to save the change
function itemCompleted(id: string) {
console.log(`completing task: ${id}`)
completeTask(id)
} }
</script> </script>
<template> <template>
<v-list v-if="!isLoading && !error"> <v-form v-if="!taskStore.isLoading && !taskStore.error">
<v-list-item v-for="task in tasks" :key="task.id"> <div class="flex p-0 m-0 bg-amber"
<div class="flex p-1 bg-amber"> v-for="task in taskStore.tasks" :key="task.id">
<div> <div>
<v-checkbox-btn @change="itemCompleted(task.id)" <v-checkbox-btn :model-value="task.completedOn !== null"
></v-checkbox-btn> @change="toggleTaskComplete(task)"
</div> ></v-checkbox-btn>
</div>
<div class="flex-column">
<div class="">
<div class="font-sans">{{ task.name }}</div>
<div class="font-serif font-normal">Description {{ task.description }}</div>
</div>
<div>{{ task.dueDate }}</div> <div class="flex-column">
<!-- <div>{{ task.project.Name }}</div>--> <div class="">
</div> <div class="font-sans font-medium">{{ task.name }}</div>
<div class="font-sans font-normal">{{ task.description }}</div>
</div> </div>
</v-list-item>
</v-list> <div>{{ task.dueDate }}</div>
</div>
</div>
</v-form>
<v-alert v-else-if="error" type="error"> <v-alert v-else-if="error" type="error">
{{ error }} {{ error }}
</v-alert> </v-alert>
<v-progress-circular v-else indeterminate></v-progress-circular> <v-progress-circular v-else indeterminate></v-progress-circular>
</template> </template>
<style scoped> <style scoped>
</style> </style>

View File

@@ -0,0 +1,5 @@
export interface Time{
time: string,
timeZone: string,
duration: string,
}

View File

@@ -1,12 +0,0 @@
import {ref, computed} from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@@ -0,0 +1,50 @@
// src/api/axiosClient.ts
import axios from 'axios'
// Resolve API base URL from Vite environment variables.
// Configure via `.env` (development) and `.env.production` (production):
// VITE_API_BASE_URL=https://localhost:7055/
// If not set, it will fall back to current origin.
console.log(`BASE URL: ${import.meta.env.VITE_API_BASE_URL}`);
const envBaseUrl = (import.meta as any)?.env?.VITE_API_BASE_URL as string | undefined
const resolvedBaseUrl = envBaseUrl && envBaseUrl.trim().length > 0
? envBaseUrl
: (typeof window !== 'undefined' ? window.location.origin : '')
console.log(`RESOLVED BASE URL: ${resolvedBaseUrl}`);
// Create an instance of axios
const httpClient = axios.create({
baseURL: resolvedBaseUrl, // API base URL from configuration
headers: {
'Content-Type': 'application/json', // Default headers
},
timeout: 5000, // Set a timeout (optional)
})
// Optionally, add request/response interceptors
httpClient.interceptors.request.use(
(config) => {
// You can modify the request here, e.g., attach auth tokens
// config.headers.Authorization = `Bearer ${yourToken}`
return config
},
(error) => {
return Promise.reject(error)
}
)
httpClient.interceptors.response.use(
(response) => {
return response
},
(error) => {
// Handle errors globally, e.g., log out on 401
if (error.response?.status === 401) {
console.error('Unauthorized, redirect to login')
}
return Promise.reject(error)
}
)
export default httpClient

View File

@@ -0,0 +1,17 @@

export interface Project {
id: string;
name: string; // [MaxLength(255)]
description?: string | null; // [MaxLength(2048)]
dueDate?: string | null;
completedOn?: string | null;
}
export interface CreateProjectRequest {
id: string;
}
export interface ChangeProjectDescription {
id: string;
completedOn: Date;
}

View File

@@ -0,0 +1,77 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { projectsApiClient } from '@/stores/projectsApiClient'
import type { Project } from '@/stores/project'
export const useProjectStore = defineStore(
'projectStore',
() => {
const projects = ref<Project[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
isLoading.value = true
error.value = null
projectsApiClient
.getProjects()
.then(t => projects.value = t)
.catch(e => error.value = (e as Error).message)
.finally(isLoading.value = false)
const fetchProjects = async () => {
isLoading.value = true
error.value = null
try {
projects.value = await projectsApiClient.getProjects()
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
const addProject = async (project: project) => {
isLoading.value = true
error.value = null
try {
const newProject = await projectsApiClient.createProject(project)
projects.value.push(newProject)
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
const completeProject = async (project: Project) => {
isLoading.value = true
error.value = null
try {
if (project.completedOn) {
await projectsApiClient.uncompleteProject(project.id)
project.completedOn = null
} else {
project.completedOn = new Date().toISOString()
await projectsApiClient.completeProject(
{
id: project.id,
completedOn: project.completedOn
})
}
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
return {
projects,
isLoading,
error,
fetchProjects,
addProject,
completeProject
}
}
)

View File

@@ -0,0 +1,44 @@
import type { Project, CreateProjectRequest, CompleteProjectRequest } from '@/stores/project'
import httpClient from '@/stores/httpClient'
export const projectsApiClient = {
// Fetch all Projects
async getProjects(): Promise<Project[]> {
const response = await httpClient.get<Project[]>('/projects')
return response.data
},
// Fetch a single Project by ID
async getProjectById(id: string): Promise<Project> {
const response = await httpClient.get<Project>(`/projects/${id}`)
return response.data
},
// Create a new Project
async createProject(request: CreateProjectRequest): Promise<Project> {
const response = await httpClient.post<Project>('/projects', request)
return response.data
},
// Update an existing Project
async completeProject(request: CompleteProjectRequest): Promise<Project> {
const response = await httpClient.put<Project>(
`/projects/complete`,
request)
return response.data
},
async uncompleteProject(id: string): Promise<Project> {
const response = await httpClient.put<Project>(
`/projects/uncomplete`,
{
id: id
})
return response.data
},
// Delete a Project
async deleteProject(id: string): Promise<void> {
await httpClient.delete(`/projects/${id}`)
},
}

View File

@@ -0,0 +1,17 @@

export interface Task {
id: string;
name: string; // [MaxLength(255)]
description?: string | null; // [MaxLength(2048)]
dueDate?: string | null;
completedOn?: string | null;
}
export interface CreateTaskRequest {
id: string;
}
export interface CompleteTaskRequest {
id: string;
completedOn: Date;
}

View File

@@ -0,0 +1,77 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { tasksApiClient } from '@/stores/tasksApiClient'
import type { Task } from '@/stores/task'
export const useTaskStore = defineStore(
'taskStore',
() => {
const tasks = ref<Task[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
isLoading.value = true
error.value = null
tasksApiClient
.getTasks()
.then(t => tasks.value = t)
.catch(e => error.value = (e as Error).message)
.finally(isLoading.value = false)
const fetchTasks = async () => {
isLoading.value = true
error.value = null
try {
tasks.value = await tasksApiClient.getTasks()
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
const addTask = async (task: Task) => {
isLoading.value = true
error.value = null
try {
const newTask = await tasksApiClient.createTask(task)
tasks.value.push(newTask)
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
const completeTask = async (task: Task) => {
isLoading.value = true
error.value = null
try {
if (task.completedOn) {
await tasksApiClient.uncompleteTask(task.id)
task.completedOn = null
} else {
task.completedOn = new Date().toISOString()
await tasksApiClient.completeTask(
{
id: task.id,
completedOn: task.completedOn
})
}
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
return {
tasks,
isLoading,
error,
fetchTasks,
addTask,
completeTask
}
}
)

View File

@@ -0,0 +1,46 @@
import type { Task, CreateTaskRequest, CompleteTaskRequest } from '@/stores/task'
import httpClient from '@/stores/httpClient'
export const tasksApiClient = {
// Fetch all Tasks
async getTasks(): Promise<Task[]> {
const response = await httpClient.get<Task[]>('/tasks')
return response.data
},
// Fetch a single Task by ID
async getTaskById(id: string): Promise<Task> {
const response = await httpClient.get<Task>(`/tasks/${id}`)
return response.data
},
// Create a new Task
async createTask(request: CreateTaskRequest): Promise<Task> {
const response = await httpClient.post<Task>(
'/tasks',
request)
return response.data
},
// Update an existing Task
async completeTask(request: CompleteTaskRequest): Promise<Task> {
const response = await httpClient.put<Task>(
`/tasks/complete`,
request)
return response.data
},
async uncompleteTask(id: string): Promise<Task> {
const response = await httpClient.put<Task>(
`/tasks/uncomplete`,
{
id: id
})
return response.data
},
// Delete a Task
async deleteTask(id: string): Promise<void> {
await httpClient.delete(`/tasks/${id}`)
}
}

View File

@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import {useCounterStore} from "@/stores/counter"; import TaskInput from '@/components/TaskInput.vue'
import TaskInput from "@/components/TaskInput.vue";
import TaskList from '@/components/TaskList.vue' import TaskList from '@/components/TaskList.vue'
const counterStore = useCounterStore()
</script> </script>
<template> <template>
<task-input></task-input>
<task-list></task-list> <v-card class="m-2 p-2">
<task-input></task-input>
</v-card>
<v-card class="m-2 p-2">
<task-list></task-list>
</v-card>
</template> </template>

View File

@@ -0,0 +1,122 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Respawn;
using Tasker.Web.Tasking.Data;
using Testcontainers.PostgreSql;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tests;
public class TaskerWebAppFactory(
PostgreSqlContainer postgres)
: WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(
IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var descriptor = services.Single(
d => d.ServiceType == typeof(DbContextOptions<ProjectDbContext>));
services.Remove(descriptor);
services.AddDbContext<ProjectDbContext>(options =>
{
options.UseNpgsql(postgres.GetConnectionString());
});
});
}
}
public sealed class InfrastructureFixture
: IAsyncLifetime
{
public PostgreSqlContainer? Postgres { get; private set; }
public TaskerWebAppFactory AppFactory { get; private set; } = null!;
public HttpClient Client { get; private set; } = null!;
public Respawner RespawnPoint { get; private set; } = null!;
public async Task InitializeAsync()
{
await CreateDatabaseContainerAsync();
await CreateDatabaseAsync();
await SeedDatabaseAsync();
await CreateDatabaseSnapshotAsync();
AppFactory = new TaskerWebAppFactory(Postgres!);
Client = AppFactory.CreateClient();
}
public async Task DisposeAsync()
{
if (Postgres is null) return;
await Postgres.StopAsync();
await Postgres.DisposeAsync();
}
private async Task CreateDatabaseContainerAsync()
{
Postgres = new PostgreSqlBuilder()
.WithDatabase("Tasker")
.WithUsername("sa")
.WithPassword("P@ssword123!")
.Build();
await Postgres.StartAsync();
}
private async Task CreateDatabaseAsync()
{
await using var context = new ProjectDbContext(GetDbContextOptions());
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
}
private async Task SeedDatabaseAsync()
{
await using var context = new ProjectDbContext(GetDbContextOptions());
await context.SeedTestDataAsync();
await context.SaveChangesAsync();
}
private async Task CreateDatabaseSnapshotAsync()
{
var connectionString = Postgres!.GetConnectionString();
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
RespawnPoint = await Respawner.CreateAsync(
connection,
new RespawnerOptions
{
SchemasToInclude = ["public"],
DbAdapter = DbAdapter.Postgres
});
}
public async Task RespawnAsync()
{
await RestoreDatabaseSnapshotAsync();
await SeedDatabaseAsync();
}
private async Task RestoreDatabaseSnapshotAsync()
{
var connectionString = Postgres!.GetConnectionString();
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await RespawnPoint.ResetAsync(connection);
}
private DbContextOptions<ProjectDbContext> GetDbContextOptions()
{
var connectionString = Postgres!.GetConnectionString();
var builder = new DbContextOptionsBuilder<ProjectDbContext>();
builder.UseNpgsql(connectionString);
return builder.Options;
}
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.0.0-alpha.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0-preview.7.24406.2" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="9.0.0-preview.7.24406.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Respawn" Version="6.2.1" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.10.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tasker.Web\Tasker.Web.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
namespace Tasker.Web.Tests;
public class TaskerWebTestBase(
InfrastructureFixture fixture)
: IClassFixture<InfrastructureFixture>, IAsyncLifetime
{
protected InfrastructureFixture Fixture { get; } = fixture;
public async Task InitializeAsync()
{
Console.WriteLine("Before each test ?");
await Fixture.RespawnAsync();
}
public Task DisposeAsync()
{
Console.WriteLine("After each test ?");
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,44 @@
using Tasker.Web.Tasking.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tests;
public static class TestData
{
public static readonly Tasking.Data.Task[] Tasks =
[
new()
{
Id = Guid.CreateVersion7(),
Name = "Test Task A - Completed",
Description = "Test Task Description A - Completed",
DueDate = DateTimeOffset.UtcNow,
CompletedOn = DateTimeOffset.UtcNow.AddHours(-1),
},
new()
{
Id = Guid.CreateVersion7(),
Name = "Test Task B",
Description = "Test Task Description B",
DueDate = DateTimeOffset.UtcNow,
},
new()
{
Id = Guid.CreateVersion7(),
Name = "Test Task C",
Description = "Test Task Description C",
DueDate = DateTimeOffset.UtcNow,
}
];
}
public static class TaskingDbContextExtensions
{
public static async Task SeedTestDataAsync(
this ProjectDbContext? context)
{
ArgumentNullException.ThrowIfNull(context);
await context.Tasks.AddRangeAsync(TestData.Tasks);
}
}

View File

@@ -0,0 +1,63 @@
using System.Net.Http.Json;
using FluentAssertions;
using Tasker.Web.Tasking.Endpoints.Models;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tests;
public class WhenGettingMyTasks(
InfrastructureFixture fixture)
: TaskerWebTestBase(fixture)
{
private const int StartingNumberOfTasks = 3;
[Fact]
public async Task EnsureWeHaveTasks()
{
var tasks = await Fixture.Client.GetFromJsonAsync<TaskDto[]>("tasks");
tasks.Should().NotBeNull();
tasks.Should().HaveCount(StartingNumberOfTasks);
}
[Fact]
public async Task EnsureWeCanCreateNewTask()
{
var newTask = new TaskDto
(
Id: Guid.NewGuid(),
CreatedOn: DateTimeOffset.UtcNow,
Name: "New Task",
Description: "New Task Description",
DueDate: DateTimeOffset.UtcNow
);
var response = await Fixture.Client.PostAsJsonAsync(
"tasks",
newTask);
var tasks = await Fixture.Client.GetFromJsonAsync<TaskDto[]>("tasks");
tasks.Should().Contain(c => c.Id == newTask.Id);
}
[Fact]
public async Task EnsureWeCanCreateNewTask_WithoutDueDate()
{
var newTask = new TaskDto
(
Id: Guid.NewGuid(),
CreatedOn: DateTimeOffset.UtcNow,
Name: "New Task",
Description: "New Task Description"
);
var response = await Fixture.Client.PostAsJsonAsync<TaskDto>(
"tasks",
newTask);
var tasks = await Fixture.Client.GetFromJsonAsync<TaskDto[]>("tasks");
tasks.Should().Contain(newTask);
}
}

View File

@@ -1,10 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace Tasker.Web.Data;
public class ProjectingDbContext(
DbContextOptions options)
: DbContext(options)
{
public DbSet<Task> Tasks { get; set; }
}

View File

@@ -1,46 +0,0 @@
using Tasker.Web.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Endpoints;
[PublicAPI]
public record AddTaskRequest(
Guid Id,
string Name,
string? Description,
DateTimeOffset? DueDate);
[PublicAPI]
public class AddTaskEndpoint(
ProjectingDbContext dbContext)
: Endpoint<AddTaskRequest>
{
public override void Configure()
{
Post("/tasks");
Options(o => o.WithTags("Projecting"));
AllowAnonymous();
}
public override async Task HandleAsync(
AddTaskRequest req,
CancellationToken ct)
{
var task = await dbContext.Tasks.AddAsync(
new Data.Task
{
Id = req.Id,
Name = req.Name,
Description = req.Description,
DueDate = req.DueDate
},
cancellationToken: ct);
await dbContext.SaveChangesAsync(ct);
await SendCreatedAtAsync<GetTaskEndpoint>(
task.Entity.Id,
task.Entity,
cancellation: ct);
}
}

View File

@@ -1,32 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Tasker.Web.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Endpoints;
[PublicAPI]
public class GetTasksEndpoint(
ProjectingDbContext dbContext)
: EndpointWithoutRequest
{
public override void Configure()
{
Get("/tasks");
Options(o => o.WithTags("Projecting"));
AllowAnonymous();
}
public override async Task HandleAsync(
CancellationToken ct)
{
var tasks = await dbContext
.Tasks
.Where(t => t.CompletedOn == null)
.ToListAsync(cancellationToken: ct);
await SendOkAsync(
tasks,
cancellation: ct);
}
}

View File

@@ -1,53 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tasker.Web.Data;
#nullable disable
namespace Tasker.Web.Migrations
{
[DbContext(typeof(ProjectingDbContext))]
partial class ProjectingDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tasker.Web.Data.Task", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Tasks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,5 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Tasker.Web.Data; using Tasker.Web.Tasking.Data;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -8,14 +8,14 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddFastEndpoints(); builder.Services.AddFastEndpoints();
builder.Services.AddDbContext<ProjectingDbContext>(o => builder.Services.AddDbContext<ProjectDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("ProjectingDbContext"))); o.UseNpgsql(builder.Configuration.GetConnectionString("TaskingDbContext")));
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("AllowSpecificOrigin", options.AddPolicy("AllowSpecificOrigin",
policy => policy =>
{ {
policy.WithOrigins("http://localhost:5173") policy.WithOrigins("http://localhost:5173", "http://localhost:5174")
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod(); .AllowAnyMethod();
}); });
@@ -40,4 +40,9 @@ if (app.Environment.IsDevelopment())
app.UseFastEndpoints(); app.UseFastEndpoints();
app.Run(); app.Run();
public partial class Program
{
}

View File

@@ -4,15 +4,23 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FastEndpoints" Version="5.28.0" /> <PackageReference Include="FastEndpoints" Version="5.29.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
<PackageReference Include="JetBrains.Annotations" Version="2024.2.0" /> <PackageReference Include="JetBrains.Annotations" Version="2024.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Folder Include="Tasking\" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -5,13 +5,13 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tasker.Web.Data; using Tasker.Web.Tasking.Data;
#nullable disable #nullable disable
namespace Tasker.Web.Migrations namespace Tasker.Web.Tasking.Data.Migrations
{ {
[DbContext(typeof(ProjectingDbContext))] [DbContext(typeof(ProjectDbContext))]
[Migration("20240811051653_Initial")] [Migration("20240811051653_Initial")]
partial class Initial partial class Initial
{ {

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace Tasker.Web.Migrations namespace Tasker.Web.Tasking.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class Initial : Migration public partial class Initial : Migration

View File

@@ -5,13 +5,13 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tasker.Web.Data; using Tasker.Web.Tasking.Data;
#nullable disable #nullable disable
namespace Tasker.Web.Migrations namespace Tasker.Web.Tasking.Data.Migrations
{ {
[DbContext(typeof(ProjectingDbContext))] [DbContext(typeof(ProjectDbContext))]
[Migration("20240812185703_AddCompletedOn")] [Migration("20240812185703_AddCompletedOn")]
partial class AddCompletedOn partial class AddCompletedOn
{ {

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace Tasker.Web.Migrations namespace Tasker.Web.Tasking.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddCompletedOn : Migration public partial class AddCompletedOn : Migration

View File

@@ -0,0 +1,143 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tasker.Web.Tasking.Data;
#nullable disable
namespace Tasker.Web.Tasking.Data.Migrations
{
[DbContext(typeof(ProjectDbContext))]
[Migration("20240909205436_AddCreatedOn")]
partial class AddCreatedOn
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tasker.Web.Tasking.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Sprint", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<DateTime>("StartDate")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Sprints");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Task", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid?>("SprintId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.HasIndex("SprintId");
b.ToTable("Tasks");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Sprint", b =>
{
b.HasOne("Tasker.Web.Tasking.Data.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId");
b.Navigation("Project");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Task", b =>
{
b.HasOne("Tasker.Web.Tasking.Data.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId");
b.HasOne("Tasker.Web.Tasking.Data.Sprint", "Sprint")
.WithMany()
.HasForeignKey("SprintId");
b.Navigation("Project");
b.Navigation("Sprint");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,135 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tasker.Web.Tasking.Data.Migrations
{
/// <inheritdoc />
public partial class AddCreatedOn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CreatedOn",
table: "Tasks",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.AddColumn<Guid>(
name: "ProjectId",
table: "Tasks",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "SprintId",
table: "Tasks",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "Projects",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Name = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Description = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Projects", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Sprints",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
Name = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
StartDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
EndDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sprints", x => x.Id);
table.ForeignKey(
name: "FK_Sprints_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Tasks_ProjectId",
table: "Tasks",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_Tasks_SprintId",
table: "Tasks",
column: "SprintId");
migrationBuilder.CreateIndex(
name: "IX_Sprints_ProjectId",
table: "Sprints",
column: "ProjectId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Projects_ProjectId",
table: "Tasks",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Sprints_SprintId",
table: "Tasks",
column: "SprintId",
principalTable: "Sprints",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Projects_ProjectId",
table: "Tasks");
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Sprints_SprintId",
table: "Tasks");
migrationBuilder.DropTable(
name: "Sprints");
migrationBuilder.DropTable(
name: "Projects");
migrationBuilder.DropIndex(
name: "IX_Tasks_ProjectId",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_SprintId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "CreatedOn",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ProjectId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "SprintId",
table: "Tasks");
}
}
}

View File

@@ -0,0 +1,158 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tasker.Web.Tasking.Data;
#nullable disable
namespace Tasker.Web.Tasking.Data.Migrations
{
[DbContext(typeof(ProjectDbContext))]
[Migration("20240910155512_AddsDeletedOn")]
partial class AddsDeletedOn
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tasker.Web.Tasking.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DeletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Sprint", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DeletedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<DateTime>("StartDate")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Sprints");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Task", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DeletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid?>("SprintId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.HasIndex("SprintId");
b.ToTable("Tasks");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Sprint", b =>
{
b.HasOne("Tasker.Web.Tasking.Data.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId");
b.Navigation("Project");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Task", b =>
{
b.HasOne("Tasker.Web.Tasking.Data.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId");
b.HasOne("Tasker.Web.Tasking.Data.Sprint", "Sprint")
.WithMany()
.HasForeignKey("SprintId");
b.Navigation("Project");
b.Navigation("Sprint");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tasker.Web.Tasking.Data.Migrations
{
/// <inheritdoc />
public partial class AddsDeletedOn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DeletedOn",
table: "Tasks",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CreatedOn",
table: "Sprints",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DeletedOn",
table: "Sprints",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CompletedOn",
table: "Projects",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DeletedOn",
table: "Projects",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeletedOn",
table: "Tasks");
migrationBuilder.DropColumn(
name: "CreatedOn",
table: "Sprints");
migrationBuilder.DropColumn(
name: "DeletedOn",
table: "Sprints");
migrationBuilder.DropColumn(
name: "CompletedOn",
table: "Projects");
migrationBuilder.DropColumn(
name: "DeletedOn",
table: "Projects");
}
}
}

View File

@@ -0,0 +1,155 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tasker.Web.Tasking.Data;
#nullable disable
namespace Tasker.Web.Tasking.Data.Migrations
{
[DbContext(typeof(ProjectDbContext))]
partial class TaskingDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tasker.Web.Tasking.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DeletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Sprint", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DeletedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<DateTime>("StartDate")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Sprints");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Task", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DeletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid?>("SprintId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.HasIndex("SprintId");
b.ToTable("Tasks");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Sprint", b =>
{
b.HasOne("Tasker.Web.Tasking.Data.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId");
b.Navigation("Project");
});
modelBuilder.Entity("Tasker.Web.Tasking.Data.Task", b =>
{
b.HasOne("Tasker.Web.Tasking.Data.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId");
b.HasOne("Tasker.Web.Tasking.Data.Sprint", "Sprint")
.WithMany()
.HasForeignKey("SprintId");
b.Navigation("Project");
b.Navigation("Sprint");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace Tasker.Web.Tasking.Data;
public class Project
{
public Guid Id { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public DateTimeOffset? CompletedOn { get; set; }
public DateTimeOffset? DeletedOn { get; set; }
[MaxLength(255)] public required string Name { get; set; }
[MaxLength(2048)] public required string? Description { get; set; }
}

View File

@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
namespace Tasker.Web.Tasking.Data;
public class ProjectDbContext(
DbContextOptions<ProjectDbContext> options)
: DbContext(options)
{
public DbSet<Project> Projects { get; set; }
public DbSet<Sprint> Sprints { get; set; }
public DbSet<Task> Tasks { get; set; }
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder
.Entity<Task>()
.HasOne<Project>(t => t.Project);
modelBuilder
.Entity<Task>()
.HasIndex(t => t.ProjectId);
}
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace Tasker.Web.Tasking.Data;
public class Sprint
{
public Guid Id { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public Guid? ProjectId { get; set; }
public Project? Project { get; set; }
[MaxLength(255)] public required string Name { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public DateTimeOffset? DeletedOn { get; set; }
}

View File

@@ -1,12 +1,23 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Tasker.Web.Data; namespace Tasker.Web.Tasking.Data;
public class Task public class Task
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid? ProjectId { get; set; }
public Project? Project { get; set; }
public Guid? SprintId { get; set; }
public Sprint? Sprint { get; set; }
[MaxLength(255)] public required string Name { get; set; } [MaxLength(255)] public required string Name { get; set; }
[MaxLength(2048)] public string? Description { get; set; } [MaxLength(2048)] public string? Description { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public DateTimeOffset? DueDate { get; set; } public DateTimeOffset? DueDate { get; set; }
public DateTimeOffset? CompletedOn { get; set; } public DateTimeOffset? CompletedOn { get; set; }
public DateTimeOffset? DeletedOn { get; set; }
} }

View File

@@ -1,7 +1,7 @@
using Tasker.Web.Data; using Tasker.Web.Tasking.Data;
using Task = System.Threading.Tasks.Task; using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Endpoints; namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI] [PublicAPI]
public record CompleteTaskRequest( public record CompleteTaskRequest(
@@ -10,13 +10,13 @@ public record CompleteTaskRequest(
[PublicAPI] [PublicAPI]
public class CompleteTaskEndpoint( public class CompleteTaskEndpoint(
ProjectingDbContext dbContext) ProjectDbContext dbContext)
: Endpoint<CompleteTaskRequest> : Endpoint<CompleteTaskRequest>
{ {
public override void Configure() public override void Configure()
{ {
Post("/tasks/{Id}/complete"); Put("/tasks/complete");
Options(o => o.WithTags("Projecting")); Options(o => o.WithTags("Tasks"));
AllowAnonymous(); AllowAnonymous();
} }
@@ -36,6 +36,12 @@ public class CompleteTaskEndpoint(
return; return;
} }
if (task.CompletedOn is not null)
{
await SendResultAsync(Results.BadRequest("The task was already completed"));
return;
}
task.CompletedOn = req.CompleteOn; task.CompletedOn = req.CompleteOn;
await dbContext.SaveChangesAsync(ct); await dbContext.SaveChangesAsync(ct);

View File

@@ -0,0 +1,47 @@
using Tasker.Web.Tasking.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI]
public record CreateProjectRequest(
Guid Id,
string Name,
string? Description);
[PublicAPI]
public class CreateProjectEndpoint(
ProjectDbContext dbContext)
: Endpoint<CreateProjectRequest>
{
public override void Configure()
{
Post("/projects");
Options(o => o.WithTags("Projects"));
AllowAnonymous();
}
public override async Task HandleAsync(
CreateProjectRequest req,
CancellationToken ct)
{
var project = await dbContext
.Projects
.AddAsync(
new Project
{
Id = req.Id,
CreatedOn = DateTimeOffset.UtcNow,
Name = req.Name,
Description = req.Description
},
cancellationToken: ct);
await dbContext.SaveChangesAsync(ct);
await SendCreatedAtAsync<GetProjectEndpoint>(
project.Entity.Id,
project.Entity,
cancellation: ct);
}
}

View File

@@ -0,0 +1,49 @@
using Tasker.Web.Tasking.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI]
public record CreateTaskRequest(
Guid Id,
string Name,
string? Description,
DateTimeOffset? DueDate);
[PublicAPI]
public class CreateTaskEndpoint(
ProjectDbContext dbContext)
: Endpoint<CreateTaskRequest>
{
public override void Configure()
{
Post("/tasks");
Options(o => o.WithTags("Tasks"));
AllowAnonymous();
}
public override async Task HandleAsync(
CreateTaskRequest req,
CancellationToken ct)
{
var task = await dbContext
.Tasks
.AddAsync(
new Data.Task
{
Id = req.Id,
CreatedOn = DateTimeOffset.UtcNow,
Name = req.Name,
Description = req.Description,
DueDate = req.DueDate?.ToUniversalTime()
},
cancellationToken: ct);
await dbContext.SaveChangesAsync(ct);
await SendCreatedAtAsync<GetTaskEndpoint>(
task.Entity.Id,
task.Entity,
cancellation: ct);
}
}

View File

@@ -0,0 +1,46 @@
using Tasker.Web.Tasking.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI]
public record DeleteProjectRequest(
Guid Id);
[PublicAPI]
public class DeleteProjectEndpoint(
ProjectDbContext dbContext)
: Endpoint<DeleteProjectRequest>
{
public override void Configure()
{
Delete("/projects/{Id}");
Options(o => o.WithTags("Projects"));
AllowAnonymous();
}
public override async Task HandleAsync(
DeleteProjectRequest req,
CancellationToken ct)
{
var project = await dbContext
.Projects
.FindAsync(
[req.Id],
ct);
if (project is null)
{
await SendNotFoundAsync(ct);
return;
}
project.DeletedOn = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
project,
cancellation: ct);
}
}

View File

@@ -0,0 +1,46 @@
using Tasker.Web.Tasking.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI]
public record DeleteTaskRequest(
Guid Id);
[PublicAPI]
public class DeleteTaskEndpoint(
ProjectDbContext dbContext)
: Endpoint<DeleteTaskRequest>
{
public override void Configure()
{
Delete("/tasks/{Id}");
Options(o => o.WithTags("Tasks"));
AllowAnonymous();
}
public override async Task HandleAsync(
DeleteTaskRequest req,
CancellationToken ct)
{
var task = await dbContext
.Tasks
.FindAsync(
[req.Id],
ct);
if (task is null)
{
await SendNotFoundAsync(ct);
return;
}
task.DeletedOn = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
task,
cancellation: ct);
}
}

View File

@@ -0,0 +1,44 @@
using Tasker.Web.Tasking.Data;
using Tasker.Web.Tasking.Endpoints.Models;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI]
public record GetProjectRequest(
Guid Id,
DateTimeOffset CreatedOn,
string Name,
string? Description);
[PublicAPI]
public class GetProjectEndpoint(
ProjectDbContext dbContext)
: Endpoint<GetProjectRequest>
{
public override void Configure()
{
Get("/projects/{Id}");
Options(o => o.WithTags("Projects"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetProjectRequest req,
CancellationToken ct)
{
var task = await dbContext
.Projects
.FindAsync(
[req.Id],
cancellationToken: ct);
await SendOkAsync(
new ProjectDto(
task.Id,
task.CreatedOn,
task.Name,
task.Description),
cancellation: ct);
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using Tasker.Web.Tasking.Data;
using Tasker.Web.Tasking.Endpoints.Models;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI]
public class GetProjectsEndpoint(
ProjectDbContext dbContext)
: EndpointWithoutRequest<ProjectDto[]>
{
public override void Configure()
{
Get("/projects");
Options(o => o.WithTags("Projects"));
AllowAnonymous();
}
public override async Task HandleAsync(
CancellationToken ct)
{
var projects = await dbContext
.Projects
.OrderByDescending(task => task.CreatedOn)
.Select(task => new ProjectDto(
task.Id,
task.CreatedOn,
task.Name,
task.Description))
.ToArrayAsync(cancellationToken: ct);
await SendOkAsync(
projects,
cancellation: ct);
}
}

View File

@@ -1,7 +1,7 @@
using Tasker.Web.Data; using Tasker.Web.Tasking.Data;
using Task = System.Threading.Tasks.Task; using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Endpoints; namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI] [PublicAPI]
public record GetTaskRequest( public record GetTaskRequest(
@@ -9,21 +9,25 @@ public record GetTaskRequest(
[PublicAPI] [PublicAPI]
public class GetTaskEndpoint( public class GetTaskEndpoint(
ProjectingDbContext dbContext) ProjectDbContext dbContext)
: Endpoint<GetTaskRequest> : Endpoint<GetTaskRequest>
{ {
public override void Configure() public override void Configure()
{ {
Get("/tasks/{Id}"); Get("/tasks/{Id}");
Options(o => o.WithTags("Projecting")); Options(o => o.WithTags("Tasks"));
AllowAnonymous();
} }
public override async Task HandleAsync( public override async Task HandleAsync(
GetTaskRequest req, GetTaskRequest req,
CancellationToken ct) CancellationToken ct)
{ {
var task = await dbContext.FindAsync<GetTaskRequest>( var task = await dbContext
req.Id); .Tasks
.FindAsync(
[req.Id],
cancellationToken: ct);
await SendOkAsync( await SendOkAsync(
task, task,

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Tasker.Web.Tasking.Data;
using Tasker.Web.Tasking.Endpoints.Models;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI]
public class GetTasksEndpoint(
ProjectDbContext dbContext)
: EndpointWithoutRequest<TaskDto[]>
{
public override void Configure()
{
Get("/tasks");
Options(o => o.WithTags("Tasks"));
AllowAnonymous();
}
public override async Task HandleAsync(
CancellationToken ct)
{
var tasks = await dbContext
.Tasks
.OrderByDescending(task => task.CompletedOn)
.Select(task => new TaskDto(
task.Id,
task.CreatedOn,
task.Name,
task.Description,
task.DueDate,
task.CompletedOn))
.ToArrayAsync(cancellationToken: ct);
await SendOkAsync(
tasks,
cancellation: ct);
}
}

View File

@@ -0,0 +1,8 @@
namespace Tasker.Web.Tasking.Endpoints.Models;
[PublicAPI]
public record struct ProjectDto(
Guid Id,
DateTimeOffset CreatedOn,
string Name,
string? Description);

View File

@@ -0,0 +1,10 @@
namespace Tasker.Web.Tasking.Endpoints.Models;
[PublicAPI]
public record TaskDto(
Guid Id,
DateTimeOffset CreatedOn,
string Name,
string? Description,
DateTimeOffset? DueDate = null,
DateTimeOffset? CompletedOn = null);

View File

@@ -0,0 +1,47 @@
using Tasker.Web.Tasking.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI]
public record RenameDescriptionRequest(
Guid Id,
string? Description);
[PublicAPI]
public class RenameDescriptionEndpoint(
ProjectDbContext dbContext)
: Endpoint<RenameDescriptionRequest>
{
public override void Configure()
{
Put("/projects/{Id}/description");
Options(o => o.WithTags("Projects"));
AllowAnonymous();
}
public override async Task HandleAsync(
RenameDescriptionRequest req,
CancellationToken ct)
{
var project = await dbContext
.Projects
.FindAsync(
[req.Id],
cancellationToken: ct);
if (project is null)
{
await SendNotFoundAsync(ct);
return;
}
project.Description = req.Description;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
project,
cancellation: ct);
}
}

View File

@@ -0,0 +1,47 @@
using Tasker.Web.Tasking.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI]
public record RenameProjectRequest(
Guid Id,
string Name);
[PublicAPI]
public class RenameProjectEndpoint(
ProjectDbContext dbContext)
: Endpoint<RenameProjectRequest>
{
public override void Configure()
{
Put("/projects/{Id}/name");
Options(o => o.WithTags("Projects"));
AllowAnonymous();
}
public override async Task HandleAsync(
RenameProjectRequest req,
CancellationToken ct)
{
var project = await dbContext
.Projects
.FindAsync(
[req.Id],
cancellationToken: ct);
if (project is null)
{
await SendNotFoundAsync(ct);
return;
}
project.Name = req.Name;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
project,
cancellation: ct);
}
}

View File

@@ -0,0 +1,52 @@
using Tasker.Web.Tasking.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Tasking.Endpoints;
[PublicAPI]
public record UnCompleteTaskRequest(
Guid Id);
[PublicAPI]
public class UnCompleteTaskEndpoint(
ProjectDbContext dbContext)
: Endpoint<UnCompleteTaskRequest>
{
public override void Configure()
{
Put("/tasks/uncomplete");
Options(o => o.WithTags("Tasks"));
AllowAnonymous();
}
public override async Task HandleAsync(
UnCompleteTaskRequest req,
CancellationToken ct)
{
var task = await dbContext
.Tasks
.FindAsync(
[req.Id],
cancellationToken: ct);
if (task is null)
{
await SendNotFoundAsync(ct);
return;
}
if (task.CompletedOn is null)
{
await SendResultAsync(Results.BadRequest("The task was already un-completed"));
return;
}
task.CompletedOn = null;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
task,
cancellation: ct);
}
}

View File

@@ -6,6 +6,6 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"ProjectingDbContext": "Server=127.0.0.1;Port=5432;Database=Tasker;User Id=sa;Password=P@ssword123!;" "TaskingDbContext": "Server=127.0.0.1;Port=5400;Database=Tasker;User Id=sa;Password=P@ssword123!;"
} }
} }

View File

@@ -1,16 +1,48 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tasker.Web", "Tasker.Web\Tasker.Web.csproj", "{5718F5B9-8EF2-4FE6-ABA4-21BC699F4023}" # Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tasker.Web", "Tasker.Web\Tasker.Web.csproj", "{301B9679-0E9A-44F3-A83C-58ABF05F38E1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tasker.Web.Tests", "Tasker.Web.Tests\Tasker.Web.Tests.csproj", "{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5718F5B9-8EF2-4FE6-ABA4-21BC699F4023}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5718F5B9-8EF2-4FE6-ABA4-21BC699F4023}.Debug|Any CPU.Build.0 = Debug|Any CPU {301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5718F5B9-8EF2-4FE6-ABA4-21BC699F4023}.Release|Any CPU.ActiveCfg = Release|Any CPU {301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Debug|x64.ActiveCfg = Debug|Any CPU
{5718F5B9-8EF2-4FE6-ABA4-21BC699F4023}.Release|Any CPU.Build.0 = Release|Any CPU {301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Debug|x64.Build.0 = Debug|Any CPU
{301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Debug|x86.ActiveCfg = Debug|Any CPU
{301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Debug|x86.Build.0 = Debug|Any CPU
{301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Release|Any CPU.Build.0 = Release|Any CPU
{301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Release|x64.ActiveCfg = Release|Any CPU
{301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Release|x64.Build.0 = Release|Any CPU
{301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Release|x86.ActiveCfg = Release|Any CPU
{301B9679-0E9A-44F3-A83C-58ABF05F38E1}.Release|x86.Build.0 = Release|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Debug|x64.ActiveCfg = Debug|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Debug|x64.Build.0 = Debug|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Debug|x86.ActiveCfg = Debug|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Debug|x86.Build.0 = Debug|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Release|Any CPU.Build.0 = Release|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Release|x64.ActiveCfg = Release|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Release|x64.Build.0 = Release|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Release|x86.ActiveCfg = Release|Any CPU
{F2F5446E-3DF3-4F9E-AD5C-4D90E4A44B97}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -1,7 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AActivatorUtilities_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb3bfd1dceaf91cf199c7d231f873541c39abbdffae998d84b37f373cf411_003FActivatorUtilities_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkServiceCollectionExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FLocal_003FSymbols_003Fsrc_003Fdotnet_003Fefcore_003F0d1256be4658567c8a24b4c027bdbb3dbd6de656_003Fsrc_003FEFCore_003FExtensions_003FEntityFrameworkServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIListSource_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95d8801ed7894a44a3b73e8b963694f8bd910_003Fb0_003Ff108f363_003FIListSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOpenApiEndpointRouteBuilderExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5afdf3ad3bf2cb0b47575971c4deac89885ddeb3b2444d56faa3cbd78cd_003FOpenApiEndpointRouteBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceCollection_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F595e4f2361fd31cb66c7e9303f46cf8f2fe77d96eea9d0e42c17cae783aa74_003FServiceCollection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F658ad1fa108317d78bcae889b6e4f9dd5327babb6582972f2dbeabf36dafa5_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

1868
Tasker/dotnet-install.sh vendored Normal file

File diff suppressed because it is too large Load Diff

24
start-infrastructure.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Set global configuration
DATA_ROOT="/var/tasker"
POSTGRES_VERSION="latest"
MONGODB_VERSION="latest"
# Start PostgreSQL
docker run -d \
--name TASKER_POSTGRES \
-v "${DATA_ROOT}/postgres:/var/lib/postgresql/data" \
-p 5400:5432 \
-e POSTGRES_USER=sa \
-e POSTGRES_PASSWORD=P@ssword123! \
-e POSTGRES_DB=Tasker \
postgres:$POSTGRES_VERSION
# Start MongoDB
docker run -d \
--name TASKER_MONGODB \
-v "${DATA_ROOT}/mongodb:/data/db" \
-p 5401:27017 \
mongo:$MONGODB_VERSION