Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/habit-checkins.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Habit check-ins checks

on:
pull_request:
paths:
- 'applications/habit-checkins/**'
- '.github/workflows/habit-checkins.yml'
push:
branches: [main]
paths:
- 'applications/habit-checkins/**'
- '.github/workflows/habit-checkins.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-24.04
defaults:
run:
working-directory: applications/habit-checkins
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '10.0.401'
- run: dotnet restore --locked-mode
- run: dotnet tool restore
- run: dotnet build -c Release --no-restore
- run: dotnet test -c Release --no-build --filter 'Category!=Cloud'
# Cloud acceptance is explicit, with a dedicated service and no Cloud secrets in CI.
13 changes: 13 additions & 0 deletions applications/habit-checkins/.config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.12",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
9 changes: 9 additions & 0 deletions applications/habit-checkins/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
PGHOST=your-cloud-service-hostname
PGPORT=5432
PGDATABASE=postgres
PGUSER=habit_app
PGPASSWORD=replace-with-runtime-password
PGSSLMODE=verify-full
PGSSLROOTCERT=/absolute/path/cloud-ca.pem
APP_TOKENS=user-a:replace-with-random-token-at-least-32-characters,user-b:another-random-token-at-least-32-characters
APP_URL=http://127.0.0.1:8080
4 changes: 4 additions & 0 deletions applications/habit-checkins/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**/bin/
**/obj/
.env
*.pem
8 changes: 8 additions & 0 deletions applications/habit-checkins/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>
31 changes: 31 additions & 0 deletions applications/habit-checkins/HabitApi/CalendarContract.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Globalization;
namespace HabitApi;

public static class CalendarContract
{
public static readonly DateOnly FirstDate = new(2000, 1, 1);
public static readonly DateOnly LastDate = new(2100, 12, 31);

public static DateOnly Date(string input)
{
if (!DateOnly.TryParseExact(input, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|| date < FirstDate || date > LastDate)
throw new ApiProblem(400, "date_must_be_yyyy_mm_dd_between_2000_and_2100");
return date;
}

public static (DateOnly Start, DateOnly End) Month(string input)
{
if (input.Length != 7) throw new ApiProblem(400, "month_must_be_yyyy_mm");
var start = Date(input + "-01");
return (start, start.AddMonths(1));
}
}

public sealed class ApiProblem(int status, string code) : Exception(code)
{
public int Status
{
get;
} = status;
}
15 changes: 15 additions & 0 deletions applications/habit-checkins/HabitApi/HabitApi.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="Npgsql" Version="10.0.3" />
</ItemGroup>
</Project>
105 changes: 105 additions & 0 deletions applications/habit-checkins/HabitApi/HabitDb.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace HabitApi;

public sealed class Habit
{
public Guid Id
{
get;
set;
} = Guid.NewGuid();
public required string OwnerId
{
get;
set;
}
public required string Name
{
get;
set;
}
public bool Archived
{
get;
set;
}
public DateTime CreatedAt
{
get;
set;
} = DateTime.UtcNow;
public List<CheckIn> CheckIns
{
get;
set;
} = [];
}

public sealed class CheckIn
{
public Guid Id
{
get;
set;
} = Guid.NewGuid();
public Guid HabitId
{
get;
set;
}
public Habit Habit
{
get;
set;
} = null!;
public DateOnly CompletedOn
{
get;
set;
}
public DateTime CreatedAt
{
get;
set;
} = DateTime.UtcNow;
}

public sealed class HabitDb(DbContextOptions<HabitDb> options) : DbContext(options)
{
public DbSet<Habit> Habits => Set<Habit>();
public DbSet<CheckIn> CheckIns => Set<CheckIn>();
protected override void OnModelCreating(ModelBuilder model)
{
model.HasDefaultSchema("habits");
model.Entity<Habit>(e =>
{
e.ToTable("habit", t => t.HasCheckConstraint("habit_name_nonblank", "length(btrim(name)) > 0"));
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").ValueGeneratedNever();
e.Property(x => x.OwnerId).HasColumnName("owner_id").HasMaxLength(64).IsRequired();
e.Property(x => x.Name).HasColumnName("name").HasMaxLength(100).IsRequired();
e.Property(x => x.Archived).HasColumnName("archived");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.HasIndex(x => new { x.OwnerId, x.CreatedAt, x.Id }).HasDatabaseName("habit_owner_created");
});
model.Entity<CheckIn>(e =>
{
e.ToTable("check_in", t => t.HasCheckConstraint("check_in_date_bound", "completed_on BETWEEN DATE '2000-01-01' AND DATE '2100-12-31'"));
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").ValueGeneratedNever();
e.Property(x => x.HabitId).HasColumnName("habit_id");
e.Property(x => x.CompletedOn).HasColumnName("completed_on").HasColumnType("date");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.HasOne(x => x.Habit).WithMany(x => x.CheckIns).HasForeignKey(x => x.HabitId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.HabitId, x.CompletedOn }).IsUnique().HasDatabaseName("uq_check_in_habit_date");
});
}
}

// dotnet-ef uses this explicit design-time path and the credential supplied to its process.
public sealed class MigrationFactory : IDesignTimeDbContextFactory<HabitDb>
{
public HabitDb CreateDbContext(string[] args) => new(new DbContextOptionsBuilder<HabitDb>()
.UseNpgsql(PgConfig.ConnectionString(), pg => pg.MigrationsHistoryTable("__EFMigrationsHistory", "habits")).Options);
}
94 changes: 94 additions & 0 deletions applications/habit-checkins/HabitApi/HabitService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace HabitApi;

public sealed record HabitView(Guid Id, string Name, bool Archived, DateTime CreatedAt);
public sealed record CheckInView(Guid Id, DateOnly CompletedOn, DateTime CreatedAt);

public sealed class HabitService(HabitDb db)
{
public async Task<HabitView> Create(string owner, string? name)
{
if (string.IsNullOrWhiteSpace(name) || name.Length > 100 || name.Contains('\0')) throw new ApiProblem(400, "invalid_habit_name");
var habit = new Habit { OwnerId = owner, Name = name.Trim() };
db.Habits.Add(habit);
await db.SaveChangesAsync();
return View(habit);
}

public Task<List<HabitView>> List(string owner) => db.Habits.AsNoTracking()
.Where(h => h.OwnerId == owner).OrderByDescending(h => h.CreatedAt).ThenByDescending(h => h.Id)
.Take(100).Select(h => new HabitView(h.Id, h.Name, h.Archived, h.CreatedAt)).ToListAsync();

private async Task<Habit> LockedOwned(string owner, Guid id)
{
// All archive/check-in/undo paths lock this row before inspecting state.
var rows = await db.Habits.FromSqlInterpolated($"SELECT * FROM habits.habit WHERE id={id} AND owner_id={owner} FOR UPDATE").ToListAsync();
return rows.SingleOrDefault() ?? throw new ApiProblem(404, "habit_not_found");
}

public async Task<CheckInView> Complete(string owner, Guid id, DateOnly date)
{
await using var tx = await db.Database.BeginTransactionAsync();
var habit = await LockedOwned(owner, id);
var existing = await db.CheckIns.SingleOrDefaultAsync(c => c.HabitId == id && c.CompletedOn == date);
if (existing is not null)
{
await tx.CommitAsync();
return View(existing);
}
if (habit.Archived) throw new ApiProblem(409, "habit_archived");
var checkIn = new CheckIn { HabitId = id, CompletedOn = date };
db.CheckIns.Add(checkIn);
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException e) when (e.InnerException is PostgresException
{ SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "uq_check_in_habit_date" })
{
// Discard the attempted write and tracking before reading the durable winner.
await tx.RollbackAsync();
db.ChangeTracker.Clear();
var winner = await db.CheckIns.AsNoTracking().SingleAsync(c => c.HabitId == id && c.CompletedOn == date);
return View(winner);
}
await tx.CommitAsync();
return View(checkIn);
}

public async Task Undo(string owner, Guid id, DateOnly date)
{
await using var tx = await db.Database.BeginTransactionAsync();
await LockedOwned(owner, id);
// Undo is permitted even after archive.
var checkIn = await db.CheckIns.SingleOrDefaultAsync(c => c.HabitId == id && c.CompletedOn == date);
if (checkIn is not null)
{
db.CheckIns.Remove(checkIn);
await db.SaveChangesAsync();
}
await tx.CommitAsync();
}

public async Task<HabitView> Archive(string owner, Guid id)
{
await using var tx = await db.Database.BeginTransactionAsync();
var habit = await LockedOwned(owner, id);
habit.Archived = true;
await db.SaveChangesAsync();
await tx.CommitAsync();
return View(habit);
}

public async Task<List<CheckInView>> History(string owner, Guid id, string month)
{
var (start, end) = CalendarContract.Month(month);
if (!await db.Habits.AnyAsync(h => h.Id == id && h.OwnerId == owner)) throw new ApiProblem(404, "habit_not_found");
return await db.CheckIns.AsNoTracking().Where(c => c.HabitId == id && c.CompletedOn >= start && c.CompletedOn < end)
.OrderBy(c => c.CompletedOn).Take(31).Select(c => new CheckInView(c.Id, c.CompletedOn, c.CreatedAt)).ToListAsync();
}

private static HabitView View(Habit h) => new(h.Id, h.Name, h.Archived, h.CreatedAt);
private static CheckInView View(CheckIn c) => new(c.Id, c.CompletedOn, c.CreatedAt);
}
Loading
Loading