Build flashsharp flashcard app

This commit is contained in:
didier committed 2026-10-22 21:18:32 -04:00
commit c97759b04c
69 files changed
+6959

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
using Flashsharp.Domain;
using Npgsql;
namespace Flashsharp.Data;
public sealed class Db
{
public Db(NpgsqlDataSource dataSource)
{
DataSource = dataSource;
}
public NpgsqlDataSource DataSource { get; }
public ValueTask<NpgsqlConnection> OpenAsync(CancellationToken cancellationToken = default)
=> DataSource.OpenConnectionAsync(cancellationToken);
public static NpgsqlDataSource Build(string connectionString)
{
var builder = new NpgsqlDataSourceBuilder(connectionString);
builder.MapEnum<SideKind>("card_side_kind");
builder.MapEnum<ReviewRating>("review_rating");
return builder.Build();
}
}
@@ -0,0 +1,217 @@
create extension if not exists pg_trgm;
create type card_side_kind as enum ('front', 'back', 'extra');
create type review_rating as enum ('again', 'hard', 'good', 'easy');
create table app_user (
id bigint generated always as identity primary key,
username text not null check (username = btrim(username)),
password_hash text not null,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create unique index app_user_username_key on app_user(lower(username));
create table auth_token (
id bigint generated always as identity primary key,
user_id bigint not null references app_user(id) on delete cascade,
label text not null check (length(label) > 0 and label = btrim(label)),
kind text not null check (kind in ('browser', 'api')),
token_hash bytea not null unique,
created_at timestamptz not null default now(),
last_used_at timestamptz,
expires_at timestamptz,
revoked_at timestamptz
);
create index auth_token_user_idx on auth_token(user_id, kind) where revoked_at is null;
create table course (
id bigint generated always as identity primary key,
name text not null check (length(name) > 0 and name = btrim(name)),
description text not null default '',
position integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index course_name_key on course(lower(name));
create table lesson (
id bigint generated always as identity primary key,
course_id bigint not null references course(id) on delete cascade,
name text not null check (length(name) > 0 and name = btrim(name)),
description text not null default '',
position integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index lesson_course_idx on lesson(course_id, position, id);
create unique index lesson_name_key on lesson(course_id, lower(name));
create table deck (
id bigint generated always as identity primary key,
lesson_id bigint not null references lesson(id) on delete cascade,
name text not null check (length(name) > 0 and name = btrim(name)),
description text not null default '',
position integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index deck_lesson_idx on deck(lesson_id, position, id);
create unique index deck_name_key on deck(lesson_id, lower(name));
create table card (
id bigint generated always as identity primary key,
created_by bigint references app_user(id) on delete set null,
search_text text not null default '',
search_vector tsvector generated always as (to_tsvector('simple', search_text)) stored,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz
);
create index card_search_vector_idx on card using gin(search_vector);
create index card_search_trgm_idx on card using gin(search_text gin_trgm_ops);
create index card_active_idx on card(id) where deleted_at is null;
create table card_side (
id bigint generated always as identity primary key,
card_id bigint not null references card(id) on delete cascade,
kind card_side_kind not null,
label text not null default '' check (label = btrim(label)),
body text not null check (length(trim(body)) > 0),
position integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index card_side_one_front on card_side(card_id) where kind = 'front';
create unique index card_side_one_back on card_side(card_id) where kind = 'back';
create index card_side_card_idx on card_side(card_id, kind, position, id);
create table card_deck (
card_id bigint not null references card(id) on delete cascade,
deck_id bigint not null references deck(id) on delete cascade,
added_at timestamptz not null default now(),
primary key (card_id, deck_id)
);
create index card_deck_deck_idx on card_deck(deck_id, card_id);
create table tag (
id bigint generated always as identity primary key,
name text not null check (name = btrim(name)),
description text not null default '',
created_at timestamptz not null default now()
);
create unique index tag_name_key on tag(lower(name));
create table card_tag (
card_id bigint not null references card(id) on delete cascade,
tag_id bigint not null references tag(id) on delete cascade,
primary key (card_id, tag_id)
);
create index card_tag_tag_idx on card_tag(tag_id, card_id);
create table review (
id bigint generated always as identity primary key,
user_id bigint not null references app_user(id) on delete cascade,
card_id bigint not null references card(id),
deck_id bigint references deck(id) on delete set null,
rating review_rating not null,
duration_ms integer not null default 0 check (duration_ms >= 0),
reviewed_at timestamptz not null default now()
);
create index review_user_reviewed_idx on review(user_id, reviewed_at desc);
create index review_card_reviewed_idx on review(card_id, reviewed_at desc);
create index review_deck_idx on review(deck_id) where deck_id is not null;
create index review_reviewed_brin on review using brin(reviewed_at);
create table card_schedule (
user_id bigint not null references app_user(id) on delete cascade,
card_id bigint not null references card(id) on delete cascade,
due_at timestamptz not null,
interval_seconds integer not null default 0 check (interval_seconds >= 0),
ease double precision not null default 2.5 check (ease >= 1.3),
repetitions integer not null default 0 check (repetitions >= 0),
lapses integer not null default 0 check (lapses >= 0),
last_review_id bigint references review(id) on delete set null,
updated_at timestamptz not null default now(),
primary key (user_id, card_id)
);
create index card_schedule_due_idx on card_schedule(user_id, due_at);
create function touch_updated_at() returns trigger
language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger course_touch_updated_at before update on course
for each row execute function touch_updated_at();
create trigger lesson_touch_updated_at before update on lesson
for each row execute function touch_updated_at();
create trigger deck_touch_updated_at before update on deck
for each row execute function touch_updated_at();
create trigger card_touch_updated_at before update on card
for each row execute function touch_updated_at();
create trigger card_side_touch_updated_at before update on card_side
for each row execute function touch_updated_at();
create function refresh_card_search_text() returns trigger
language plpgsql as $$
declare
target_card bigint;
begin
target_card := coalesce(new.card_id, old.card_id);
update card
set search_text = coalesce((
select string_agg(side.body, E'\n\n' order by side.position, side.id)
from card_side side
where side.card_id = target_card
), '')
where id = target_card;
return null;
end;
$$;
create trigger card_side_refresh_search
after insert or update or delete on card_side
for each row execute function refresh_card_search_text();
create function ensure_card_has_front() returns trigger
language plpgsql as $$
declare
target_card bigint;
begin
target_card := coalesce(new.card_id, old.card_id);
if not exists (select 1 from card where id = target_card) then
return null;
end if;
if not exists (
select 1 from card_side where card_id = target_card and kind = 'front'
) then
raise exception 'card % must have a front side', target_card;
end if;
return null;
end;
$$;
create constraint trigger card_requires_front
after insert or update or delete on card_side
deferrable initially deferred
for each row execute function ensure_card_has_front();
+67
View File
@@ -0,0 +1,67 @@
using System.Reflection;
using Dapper;
namespace Flashsharp.Data;
public static class Migrator
{
const string LockName = "flashsharp:migrations";
public static async Task RunAsync(Db db, ILogger logger, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync("select pg_advisory_lock(hashtext(@name))", new { name = LockName });
try
{
await connection.ExecuteAsync(
"""
create table if not exists schema_migration (
version text primary key,
applied_at timestamptz not null default now()
)
""");
var applied = (await connection.QueryAsync<string>("select version from schema_migration"))
.ToHashSet(StringComparer.Ordinal);
foreach (var migration in LoadMigrations())
{
if (applied.Contains(migration.Version))
{
continue;
}
logger.LogInformation("Applying migration {Version}", migration.Version);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
await connection.ExecuteAsync(migration.Sql, transaction: transaction);
await connection.ExecuteAsync(
"insert into schema_migration (version) values (@version)",
new { migration.Version },
transaction);
await transaction.CommitAsync(cancellationToken);
}
}
finally
{
await connection.ExecuteAsync("select pg_advisory_unlock(hashtext(@name))", new { name = LockName });
}
}
static IEnumerable<(string Version, string Sql)> LoadMigrations()
{
var assembly = Assembly.GetExecutingAssembly();
var prefix = "Flashsharp.Data.Migrations.";
var names = assembly.GetManifestResourceNames()
.Where(name => name.StartsWith(prefix, StringComparison.Ordinal) && name.EndsWith(".sql", StringComparison.Ordinal))
.OrderBy(name => name, StringComparer.Ordinal);
foreach (var name in names)
{
using var stream = assembly.GetManifestResourceStream(name)
?? throw new InvalidOperationException($"Missing migration resource {name}.");
using var reader = new StreamReader(stream);
yield return (name[prefix.Length..^4], reader.ReadToEnd());
}
}
}
+272
View File
@@ -0,0 +1,272 @@
using System.Security.Cryptography;
using System.Text;
using Dapper;
using Flashsharp.Domain;
using Microsoft.AspNetCore.WebUtilities;
namespace Flashsharp.Data;
public sealed partial class Store
{
const int PasswordWorkFactor = 12;
const string TokenPrefix = "fsh_";
public async Task<int> CountUsersAsync(CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>("select count(*) from app_user");
}
public async Task SeedAsync(
string? username,
string? password,
string? passwordHash,
CancellationToken cancellationToken = default)
{
if (await CountUsersAsync(cancellationToken) > 0)
{
return;
}
if (string.IsNullOrWhiteSpace(username)
|| (string.IsNullOrEmpty(password) && string.IsNullOrWhiteSpace(passwordHash)))
{
throw new InvalidOperationException(
"No users exist yet. Set FLASHSHARP_SEED_USERNAME and one of " +
"FLASHSHARP_SEED_PASSWORD, FLASHSHARP_SEED_PASSWORD_FILE, " +
"FLASHSHARP_SEED_PASSWORD_HASH or FLASHSHARP_SEED_PASSWORD_HASH_FILE " +
"to create the first user.");
}
var normalized = username.Trim();
var hash = string.IsNullOrWhiteSpace(passwordHash)
? BCrypt.Net.BCrypt.HashPassword(password!, PasswordWorkFactor)
: passwordHash.Trim();
if (!hash.StartsWith("$2", StringComparison.Ordinal))
{
throw new InvalidOperationException("The seed password hash must be a bcrypt hash.");
}
await InsertUserAsync(normalized, hash, cancellationToken);
}
public async Task<User> CreateUserAsync(
string username,
string password,
CancellationToken cancellationToken = default)
{
var normalized = username.Trim();
if (normalized.Length == 0)
{
throw new ArgumentException("Username must not be empty.", nameof(username));
}
if (password.Length < 8)
{
throw new ArgumentException("Password must be at least 8 characters.", nameof(password));
}
var hash = BCrypt.Net.BCrypt.HashPassword(password, PasswordWorkFactor);
return await InsertUserAsync(normalized, hash, cancellationToken);
}
async Task<User> InsertUserAsync(string username, string passwordHash, CancellationToken cancellationToken)
{
await using var connection = await db.OpenAsync(cancellationToken);
var id = await connection.ExecuteScalarAsync<long>(
"""
insert into app_user (username, password_hash)
values (@username, @passwordHash)
returning id
""",
new { username, passwordHash });
return new User(id, username);
}
public async Task<User?> GetUserByUsernameAsync(string username, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleOrDefaultAsync<User>(
"""select id as "Id", username as "Username" from app_user where lower(username) = lower(@username) and is_active""",
new { username });
}
public async Task<User?> GetUserAsync(long id, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleOrDefaultAsync<User>(
"""select id as "Id", username as "Username" from app_user where id = @id and is_active""",
new { id });
}
public async Task<IReadOnlyList<User>> ListUsersAsync(CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<User>(
"""select id as "Id", username as "Username" from app_user where is_active order by lower(username)""");
return rows.ToList();
}
public async Task DeactivateUserAsync(long id, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync("update app_user set is_active = false where id = @id", new { id });
}
public async Task<bool> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var hash = await connection.QuerySingleOrDefaultAsync<string>(
"""select password_hash from app_user where lower(username) = lower(@username) and is_active""",
new { username });
return hash is not null && BCrypt.Net.BCrypt.Verify(password, hash);
}
public async Task ChangePasswordAsync(long userId, string newPassword, CancellationToken cancellationToken = default)
{
if (newPassword.Length < 8)
{
throw new ArgumentException("Password must be at least 8 characters.", nameof(newPassword));
}
var hash = BCrypt.Net.BCrypt.HashPassword(newPassword, PasswordWorkFactor);
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync(
"update app_user set password_hash = @hash where id = @userId",
new { hash, userId });
}
public async Task<(AuthToken Token, string Raw)> CreateTokenAsync(
long userId,
string label,
string kind,
TimeSpan? lifetime,
CancellationToken cancellationToken = default)
{
var raw = TokenPrefix + WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
var hash = HashToken(raw);
var expiresAt = lifetime is null ? (DateTime?)null : DateTime.UtcNow.Add(lifetime.Value);
await using var connection = await db.OpenAsync(cancellationToken);
var row = await connection.QuerySingleAsync<AuthToken>(
"""
insert into auth_token (user_id, label, kind, token_hash, expires_at)
values (@userId, @label, @kind, @hash, @expiresAt)
returning id as "Id", label as "Label", kind as "Kind",
created_at as "CreatedAt", last_used_at as "LastUsedAt", expires_at as "ExpiresAt"
""",
new { userId, label = label.Trim(), kind, hash, expiresAt });
return (row, raw);
}
public async Task<IReadOnlyList<AuthToken>> ListTokensAsync(
long userId,
string kind,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<AuthToken>(
"""
select id as "Id", label as "Label", kind as "Kind",
created_at as "CreatedAt", last_used_at as "LastUsedAt", expires_at as "ExpiresAt"
from auth_token
where user_id = @userId and kind = @kind and revoked_at is null
order by created_at desc
""",
new { userId, kind });
return rows.ToList();
}
public async Task<bool> RevokeTokenAsync(long userId, long tokenId, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var affected = await connection.ExecuteAsync(
"""
update auth_token set revoked_at = now()
where id = @tokenId and user_id = @userId and revoked_at is null
""",
new { tokenId, userId });
return affected > 0;
}
public async Task RevokeOtherBrowserTokensAsync(
long userId,
long? exceptTokenId,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync(
"""
update auth_token set revoked_at = now()
where user_id = @userId
and kind = 'browser'
and revoked_at is null
and (@exceptTokenId::bigint is null or id <> @exceptTokenId)
""",
new { userId, exceptTokenId });
}
public async Task<(User User, AuthToken Token)?> AuthenticateTokenAsync(
string rawToken,
CancellationToken cancellationToken = default)
{
var hash = HashToken(rawToken);
await using var connection = await db.OpenAsync(cancellationToken);
var row = await connection.QuerySingleOrDefaultAsync<AuthLookup>(
"""
select t.id as "TokenId", t.label as "Label", t.kind as "Kind",
t.created_at as "TokenCreatedAt", t.last_used_at as "LastUsedAt", t.expires_at as "ExpiresAt",
u.id as "UserId", u.username as "Username"
from auth_token t
join app_user u on u.id = t.user_id
where t.token_hash = @hash
and t.revoked_at is null
and (t.expires_at is null or t.expires_at > now())
and u.is_active
""",
new { hash });
if (row is null)
{
return null;
}
var token = new AuthToken
{
Id = row.TokenId,
Label = row.Label,
Kind = row.Kind,
CreatedAt = row.TokenCreatedAt,
LastUsedAt = row.LastUsedAt,
ExpiresAt = row.ExpiresAt
};
return (new User(row.UserId, row.Username), token);
}
public async Task TouchTokenAsync(long tokenId, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync(
"""
update auth_token
set last_used_at = now()
where id = @tokenId
and (last_used_at is null or last_used_at < now() - interval '5 minutes')
""",
new { tokenId });
}
static byte[] HashToken(string rawToken) => SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
sealed class AuthLookup
{
public long TokenId { get; init; }
public string Label { get; init; } = "";
public string Kind { get; init; } = "";
public DateTime TokenCreatedAt { get; init; }
public DateTime? LastUsedAt { get; init; }
public DateTime? ExpiresAt { get; init; }
public long UserId { get; init; }
public string Username { get; init; } = "";
}
}
+467
View File
@@ -0,0 +1,467 @@
using System.Data;
using Dapper;
using Flashsharp.Domain;
using Npgsql;
namespace Flashsharp.Data;
public sealed partial class Store
{
public async Task<long> CreateCardAsync(
long deckId,
long userId,
CardDraft draft,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var cardId = await InsertCardAsync(connection, transaction, deckId, userId, draft, cancellationToken);
await transaction.CommitAsync(cancellationToken);
return cardId;
}
public async Task<IReadOnlyList<long>> CreateCardsAsync(
long deckId,
long userId,
IReadOnlyList<CardDraft> drafts,
CancellationToken cancellationToken = default)
{
if (drafts.Count == 0)
{
return [];
}
if (drafts.Count > 200)
{
throw new ArgumentException("At most 200 cards can be created in one call.", nameof(drafts));
}
await using var connection = await db.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var ids = new List<long>(drafts.Count);
foreach (var draft in drafts)
{
ids.Add(await InsertCardAsync(connection, transaction, deckId, userId, draft, cancellationToken));
}
await transaction.CommitAsync(cancellationToken);
return ids;
}
static async Task<long> InsertCardAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
long deckId,
long userId,
CardDraft draft,
CancellationToken cancellationToken)
{
var front = RequireBody(draft.Front, "front");
var back = string.IsNullOrWhiteSpace(draft.Back) ? null : draft.Back.Trim();
var extras = (draft.Extras ?? [])
.Where(extra => !string.IsNullOrWhiteSpace(extra.Body))
.Select(extra => new ExtraSide((extra.Label ?? "").Trim(), extra.Body.Trim()))
.ToList();
var cardId = await connection.ExecuteScalarAsync<long>(
"insert into card (created_by) values (@userId) returning id",
new { userId },
transaction);
await InsertSideAsync(connection, transaction, cardId, SideKind.Front, "", front, 0);
if (back is not null)
{
await InsertSideAsync(connection, transaction, cardId, SideKind.Back, "", back, 0);
}
for (var index = 0; index < extras.Count; index++)
{
await InsertSideAsync(connection, transaction, cardId, SideKind.Extra, extras[index].Label, extras[index].Body, index + 1);
}
await connection.ExecuteAsync(
"insert into card_deck (card_id, deck_id) values (@cardId, @deckId)",
new { cardId, deckId },
transaction);
await SetTagsAsync(connection, transaction, cardId, draft.Tags);
return cardId;
}
static Task InsertSideAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
long cardId,
SideKind kind,
string label,
string body,
int position)
=> connection.ExecuteAsync(
"""
insert into card_side (card_id, kind, label, body, position)
values (@cardId, @kind::card_side_kind, @label, @body, @position)
""",
new { cardId, kind = kind.ToString().ToLowerInvariant(), label, body, position },
transaction);
public async Task<Card?> GetCardAsync(long cardId, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var header = await connection.QuerySingleOrDefaultAsync<CardHeader>(
"""select id as "Id", created_at as "CreatedAt" from card where id = @cardId and deleted_at is null""",
new { cardId });
if (header is null)
{
return null;
}
var sides = await connection.QueryAsync<Side>(
"""
select id as "Id", kind as "Kind", label as "Label", body as "Body", position as "Position"
from card_side
where card_id = @cardId
order by case kind when 'front' then 0 when 'back' then 1 else 2 end, position, id
""",
new { cardId });
return new Card(header.Id, header.CreatedAt, sides.ToList());
}
public async Task<bool> CardExistsAsync(long cardId, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(
"select exists(select 1 from card where id = @cardId and deleted_at is null)",
new { cardId });
}
public async Task<CardRef?> GetCardRefAsync(long cardId, CancellationToken cancellationToken = default)
{
var refs = await LoadCardRefsAsync([cardId], cancellationToken);
return refs.GetValueOrDefault(cardId);
}
public async Task<IReadOnlyList<CardRef>> ListDeckCardsAsync(
long deckId,
int limit,
int offset,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var ids = (await connection.QueryAsync<long>(
"""
select c.id
from card_deck cd
join card c on c.id = cd.card_id
where cd.deck_id = @deckId and c.deleted_at is null
order by c.created_at desc, c.id desc
limit @limit offset @offset
""",
new { deckId, limit, offset })).ToList();
var refs = await LoadCardRefsAsync(ids, cancellationToken);
return ids.Where(refs.ContainsKey).Select(id => refs[id]).ToList();
}
public async Task<IReadOnlyList<CardRef>> SearchCardsAsync(
string? query,
long? courseId,
long? lessonId,
long? deckId,
string? tag,
int limit,
CancellationToken cancellationToken = default)
{
var q = (query ?? "").Trim();
var tagFilter = (tag ?? "").Trim();
await using var connection = await db.OpenAsync(cancellationToken);
var ids = (await connection.QueryAsync<long>(
"""
select c.id
from card c
where c.deleted_at is null
and (@q = ''
or c.search_vector @@ websearch_to_tsquery('simple', @q)
or c.search_text ilike '%' || @q || '%')
and (@tag = ''
or exists (
select 1 from card_tag ct
join tag t on t.id = ct.tag_id
where ct.card_id = c.id and lower(t.name) = lower(@tag)))
and (@courseId::bigint is null
or exists (
select 1 from card_deck cd
join deck d on d.id = cd.deck_id
join lesson l on l.id = d.lesson_id
where cd.card_id = c.id and l.course_id = @courseId))
and (@lessonId::bigint is null
or exists (
select 1 from card_deck cd
join deck d on d.id = cd.deck_id
where cd.card_id = c.id and d.lesson_id = @lessonId))
and (@deckId::bigint is null
or exists (
select 1 from card_deck cd
where cd.card_id = c.id and cd.deck_id = @deckId))
order by
case when @q = '' then 0 else
ts_rank(c.search_vector, websearch_to_tsquery('simple', @q)) + similarity(c.search_text, @q)
end desc,
c.created_at desc,
c.id desc
limit @limit
""",
new { q, tag = tagFilter, courseId, lessonId, deckId, limit })).ToList();
var refs = await LoadCardRefsAsync(ids, cancellationToken);
return ids.Where(refs.ContainsKey).Select(id => refs[id]).ToList();
}
public async Task<IReadOnlyList<TagInfo>> ListTagsAsync(CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<TagInfo>(
"""
select t.name as "Name",
t.description as "Description",
count(c.id)::int as "CardCount"
from tag t
left join card_tag ct on ct.tag_id = t.id
left join card c on c.id = ct.card_id and c.deleted_at is null
group by t.id
order by lower(t.name)
""");
return rows.ToList();
}
public async Task<string> CreateTagAsync(
string name,
string description = "",
CancellationToken cancellationToken = default)
{
var clean = (name ?? "").Trim();
if (clean.Length == 0)
{
throw new ArgumentException("Tag name must not be empty.", nameof(name));
}
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleAsync<string>(
"""
insert into tag (name, description)
values (@clean, @description)
on conflict (lower(name)) do update
set description = case when excluded.description = '' then tag.description else excluded.description end
returning name
""",
new { clean, description = description ?? "" });
}
public async Task UpdateCardAsync(
long cardId,
CardDraft draft,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var exists = await connection.ExecuteScalarAsync<bool>(
"""select exists(select 1 from card where id = @cardId and deleted_at is null)""",
new { cardId },
transaction);
if (!exists)
{
throw new InvalidOperationException($"Card {cardId} does not exist.");
}
await connection.ExecuteAsync("delete from card_side where card_id = @cardId", new { cardId }, transaction);
await connection.ExecuteAsync("delete from card_tag where card_id = @cardId", new { cardId }, transaction);
var front = RequireBody(draft.Front, "front");
var back = string.IsNullOrWhiteSpace(draft.Back) ? null : draft.Back.Trim();
var extras = (draft.Extras ?? [])
.Where(extra => !string.IsNullOrWhiteSpace(extra.Body))
.Select(extra => new ExtraSide((extra.Label ?? "").Trim(), extra.Body.Trim()))
.ToList();
await InsertSideAsync(connection, transaction, cardId, SideKind.Front, "", front, 0);
if (back is not null)
{
await InsertSideAsync(connection, transaction, cardId, SideKind.Back, "", back, 0);
}
for (var index = 0; index < extras.Count; index++)
{
await InsertSideAsync(connection, transaction, cardId, SideKind.Extra, extras[index].Label, extras[index].Body, index + 1);
}
await SetTagsAsync(connection, transaction, cardId, draft.Tags);
await transaction.CommitAsync(cancellationToken);
}
public async Task DeleteCardAsync(long cardId, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync(
"update card set deleted_at = now() where id = @cardId and deleted_at is null",
new { cardId });
}
public async Task AddCardToDeckAsync(long cardId, long deckId, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync(
"""
insert into card_deck (card_id, deck_id)
select c.id, d.id from card c, deck d
where c.id = @cardId and d.id = @deckId and c.deleted_at is null
on conflict (card_id, deck_id) do nothing
""",
new { cardId, deckId });
}
public async Task RemoveCardFromDeckAsync(long cardId, long deckId, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync(
"delete from card_deck where card_id = @cardId and deck_id = @deckId",
new { cardId, deckId });
}
static async Task SetTagsAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
long cardId,
IReadOnlyList<string>? tags)
{
var clean = (tags ?? [])
.Select(tag => (tag ?? "").Trim())
.Where(tag => tag.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var tag in clean)
{
var tagId = await connection.ExecuteScalarAsync<long>(
"""
insert into tag (name) values (@tag)
on conflict (lower(name)) do update set name = excluded.name
returning id
""",
new { tag },
transaction);
await connection.ExecuteAsync(
"insert into card_tag (card_id, tag_id) values (@cardId, @tagId) on conflict do nothing",
new { cardId, tagId },
transaction);
}
}
async Task<IReadOnlyDictionary<long, CardRef>> LoadCardRefsAsync(
IReadOnlyList<long> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return new Dictionary<long, CardRef>();
}
await using var connection = await db.OpenAsync(cancellationToken);
var sides = (await connection.QueryAsync<SideRow>(
"""
select card_id as "CardId", kind as "Kind", label as "Label", body as "Body", position as "Position"
from card_side
where card_id = any(@ids)
order by case kind when 'front' then 0 when 'back' then 1 else 2 end, position, id
""",
new { ids })).ToList();
var tags = (await connection.QueryAsync<TagRow>(
"""
select ct.card_id as "CardId", t.name as "Name"
from card_tag ct
join tag t on t.id = ct.tag_id
where ct.card_id = any(@ids)
order by lower(t.name)
""",
new { ids })).ToList();
var decks = (await connection.QueryAsync<CardDeckRow>(
"""
select cd.card_id as "CardId",
d.id as "Id", d.name as "Name",
l.id as "LessonId", l.name as "LessonName",
co.id as "CourseId", co.name as "CourseName"
from card_deck cd
join deck d on d.id = cd.deck_id
join lesson l on l.id = d.lesson_id
join course co on co.id = l.course_id
where cd.card_id = any(@ids)
order by lower(co.name), lower(l.name), lower(d.name)
""",
new { ids })).ToList();
var sidesByCard = sides.GroupBy(row => row.CardId).ToDictionary(group => group.Key, group => group.ToList());
var tagsByCard = tags.GroupBy(row => row.CardId).ToDictionary(group => group.Key, group => group.Select(row => row.Name).ToList());
var decksByCard = decks.GroupBy(row => row.CardId).ToDictionary(group => group.Key, group => group
.Select(row => new DeckRef(row.Id, row.Name, row.LessonId, row.LessonName, row.CourseId, row.CourseName))
.ToList());
var result = new Dictionary<long, CardRef>(ids.Count);
foreach (var id in ids)
{
if (!sidesByCard.TryGetValue(id, out var cardSides))
{
continue;
}
var front = cardSides.FirstOrDefault(side => side.Kind == SideKind.Front)?.Body ?? "";
var back = cardSides.FirstOrDefault(side => side.Kind == SideKind.Back)?.Body;
result[id] = new CardRef(
id,
front,
back,
tagsByCard.GetValueOrDefault(id, []),
decksByCard.GetValueOrDefault(id, []));
}
return result;
}
static string RequireBody(string body, string label)
{
var clean = (body ?? "").Trim();
if (clean.Length == 0)
{
throw new ArgumentException($"The {label} side must not be empty.");
}
return clean;
}
sealed class CardHeader
{
public long Id { get; init; }
public DateTime CreatedAt { get; init; }
}
sealed class SideRow
{
public long CardId { get; init; }
public SideKind Kind { get; init; }
public string Label { get; init; } = "";
public string Body { get; init; } = "";
public int Position { get; init; }
}
sealed class TagRow
{
public long CardId { get; init; }
public string Name { get; init; } = "";
}
sealed class CardDeckRow
{
public long CardId { get; init; }
public long Id { get; init; }
public string Name { get; init; } = "";
public long LessonId { get; init; }
public string LessonName { get; init; } = "";
public long CourseId { get; init; }
public string CourseName { get; init; } = "";
}
}
+339
View File
@@ -0,0 +1,339 @@
using Dapper;
using Flashsharp.Domain;
namespace Flashsharp.Data;
public sealed partial class Store
{
const string CourseSummarySql =
"""
select co.id as "Id",
co.name as "Name",
co.description as "Description",
co.position as "Position",
(select count(*) from lesson l where l.course_id = co.id)::int as "LessonCount",
(select count(*) from deck d join lesson l on l.id = d.lesson_id where l.course_id = co.id)::int as "DeckCount",
(select count(distinct c.id)
from card_deck cd
join deck d on d.id = cd.deck_id
join lesson l on l.id = d.lesson_id
join card c on c.id = cd.card_id
where l.course_id = co.id and c.deleted_at is null)::int as "CardCount",
(select count(distinct c.id)
from card_deck cd
join deck d on d.id = cd.deck_id
join lesson l on l.id = d.lesson_id
join card c on c.id = cd.card_id
left join card_schedule s on s.card_id = c.id and s.user_id = @userId
where l.course_id = co.id
and c.deleted_at is null
and (s.user_id is null or s.due_at <= now()))::int as "DueCount"
from course co
""";
const string LessonSummarySql =
"""
select l.id as "Id",
l.course_id as "CourseId",
l.name as "Name",
l.description as "Description",
l.position as "Position",
(select count(*) from deck d where d.lesson_id = l.id)::int as "DeckCount",
(select count(distinct c.id)
from card_deck cd
join deck d on d.id = cd.deck_id
join card c on c.id = cd.card_id
where d.lesson_id = l.id and c.deleted_at is null)::int as "CardCount",
(select count(distinct c.id)
from card_deck cd
join deck d on d.id = cd.deck_id
join card c on c.id = cd.card_id
left join card_schedule s on s.card_id = c.id and s.user_id = @userId
where d.lesson_id = l.id
and c.deleted_at is null
and (s.user_id is null or s.due_at <= now()))::int as "DueCount"
from lesson l
""";
const string DeckSummarySql =
"""
select d.id as "Id",
d.lesson_id as "LessonId",
l.course_id as "CourseId",
l.name as "LessonName",
co.name as "CourseName",
d.name as "Name",
d.description as "Description",
d.position as "Position",
(select count(*) from card_deck cd join card c on c.id = cd.card_id
where cd.deck_id = d.id and c.deleted_at is null)::int as "CardCount",
(select count(*)
from card_deck cd
join card c on c.id = cd.card_id
left join card_schedule s on s.card_id = c.id and s.user_id = @userId
where cd.deck_id = d.id
and c.deleted_at is null
and (s.user_id is null or s.due_at <= now()))::int as "DueCount"
from deck d
join lesson l on l.id = d.lesson_id
join course co on co.id = l.course_id
""";
public async Task<IReadOnlyList<CourseSummary>> ListCoursesAsync(
long userId,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<CourseSummary>(
$"{CourseSummarySql} order by co.position, lower(co.name), co.id",
new { userId });
return rows.ToList();
}
public async Task<CourseSummary?> GetCourseAsync(
long courseId,
long userId,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleOrDefaultAsync<CourseSummary>(
$"{CourseSummarySql} where co.id = @courseId",
new { courseId, userId });
}
public async Task<IReadOnlyList<LessonSummary>> ListLessonsAsync(
long courseId,
long userId,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<LessonSummary>(
$"{LessonSummarySql} where l.course_id = @courseId order by l.position, lower(l.name), l.id",
new { courseId, userId });
return rows.ToList();
}
public async Task<LessonSummary?> GetLessonAsync(
long lessonId,
long userId,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleOrDefaultAsync<LessonSummary>(
$"{LessonSummarySql} where l.id = @lessonId",
new { lessonId, userId });
}
public async Task<IReadOnlyList<DeckSummary>> ListDecksAsync(
long lessonId,
long userId,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<DeckSummary>(
$"{DeckSummarySql} where d.lesson_id = @lessonId order by d.position, lower(d.name), d.id",
new { lessonId, userId });
return rows.ToList();
}
public async Task<DeckSummary?> GetDeckAsync(
long deckId,
long userId,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleOrDefaultAsync<DeckSummary>(
$"{DeckSummarySql} where d.id = @deckId",
new { deckId, userId });
}
public async Task<bool> CourseExistsAsync(long courseId, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(
"select exists(select 1 from course where id = @courseId)",
new { courseId });
}
public async Task<bool> LessonExistsAsync(long lessonId, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(
"select exists(select 1 from lesson where id = @lessonId)",
new { lessonId });
}
public async Task<DeckContext?> GetDeckContextAsync(
long deckId,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleOrDefaultAsync<DeckContext>(
"""
select d.id as "Id", d.name as "Name",
l.id as "LessonId", l.name as "LessonName",
co.id as "CourseId", co.name as "CourseName"
from deck d
join lesson l on l.id = d.lesson_id
join course co on co.id = l.course_id
where d.id = @deckId
""",
new { deckId });
}
public async Task<IReadOnlyList<CourseNode>> GetTreeAsync(CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var courses = (await connection.QueryAsync<CourseRow>(
"""select id as "Id", name as "Name", description as "Description", position as "Position" from course order by position, lower(name), id"""
)).ToList();
var lessons = (await connection.QueryAsync<LessonRow>(
"""select id as "Id", course_id as "CourseId", name as "Name", description as "Description", position as "Position" from lesson order by position, lower(name), id"""
)).ToList();
var decks = (await connection.QueryAsync<DeckRow>(
"""
select d.id as "Id", d.lesson_id as "LessonId", d.name as "Name", d.description as "Description",
d.position as "Position",
(select count(*) from card_deck cd join card c on c.id = cd.card_id
where cd.deck_id = d.id and c.deleted_at is null)::int as "CardCount"
from deck d
order by d.position, lower(d.name), d.id
"""
)).ToList();
var decksByLesson = decks
.GroupBy(deck => deck.LessonId)
.ToDictionary(group => group.Key, group => (IReadOnlyList<DeckNode>)group
.Select(deck => new DeckNode(deck.Id, deck.Name, deck.Description, deck.Position, deck.CardCount))
.ToList());
var lessonsByCourse = lessons
.GroupBy(lesson => lesson.CourseId)
.ToDictionary(group => group.Key, group => (IReadOnlyList<LessonNode>)group
.Select(lesson => new LessonNode(
lesson.Id,
lesson.Name,
lesson.Description,
lesson.Position,
decksByLesson.GetValueOrDefault(lesson.Id, [])))
.ToList());
return courses
.Select(course => new CourseNode(
course.Id,
course.Name,
course.Description,
course.Position,
lessonsByCourse.GetValueOrDefault(course.Id, [])))
.ToList();
}
sealed class CourseRow
{
public long Id { get; init; }
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public int Position { get; init; }
}
public async Task<EntityRef> CreateCourseAsync(
string name,
string description = "",
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleAsync<EntityRef>(
"""
insert into course (name, description, position)
values (@name, @description, coalesce((select max(position) + 1 from course), 0))
on conflict (lower(name)) do update set name = excluded.name
returning id as "Id", name as "Name"
""",
new { name = CleanName(name), description = description ?? "" });
}
public async Task<EntityRef> CreateLessonAsync(
long courseId,
string name,
string description = "",
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleAsync<EntityRef>(
"""
insert into lesson (course_id, name, description, position)
values (@courseId, @name, @description,
coalesce((select max(position) + 1 from lesson where course_id = @courseId), 0))
on conflict (course_id, lower(name)) do update set name = excluded.name
returning id as "Id", name as "Name"
""",
new { courseId, name = CleanName(name), description = description ?? "" });
}
public async Task<EntityRef> CreateDeckAsync(
long lessonId,
string name,
string description = "",
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
return await connection.QuerySingleAsync<EntityRef>(
"""
insert into deck (lesson_id, name, description, position)
values (@lessonId, @name, @description,
coalesce((select max(position) + 1 from deck where lesson_id = @lessonId), 0))
on conflict (lesson_id, lower(name)) do update set name = excluded.name
returning id as "Id", name as "Name"
""",
new { lessonId, name = CleanName(name), description = description ?? "" });
}
public async Task RenameCourseAsync(long courseId, string name, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync("update course set name = @name where id = @courseId", new { courseId, name = CleanName(name) });
}
public async Task RenameLessonAsync(long lessonId, string name, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync("update lesson set name = @name where id = @lessonId", new { lessonId, name = CleanName(name) });
}
public async Task RenameDeckAsync(long deckId, string name, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await connection.ExecuteAsync("update deck set name = @name where id = @deckId", new { deckId, name = CleanName(name) });
}
static string CleanName(string name)
{
var trimmed = name.Trim();
if (trimmed.Length == 0)
{
throw new ArgumentException("Name must not be empty.", nameof(name));
}
return trimmed;
}
sealed class LessonRow
{
public long Id { get; init; }
public long CourseId { get; init; }
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public int Position { get; init; }
}
sealed class DeckRow
{
public long Id { get; init; }
public long LessonId { get; init; }
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public int Position { get; init; }
public int CardCount { get; init; }
}
}
+188
View File
@@ -0,0 +1,188 @@
using Dapper;
using Flashsharp.Domain;
namespace Flashsharp.Data;
public sealed partial class Store
{
public async Task<StatsSummary> GetStatsSummaryAsync(long userId, CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var row = await connection.QuerySingleAsync<SummaryRow>(
"""
select (select count(*) from review where user_id = @userId)::int as "TotalReviews",
(select count(*) from review
where user_id = @userId
and (reviewed_at at time zone 'UTC')::date = (now() at time zone 'UTC')::date)::int as "ReviewsToday",
(select count(distinct card_id) from review where user_id = @userId)::int as "CardsSeen",
(select count(*)
from card c
where c.deleted_at is null
and exists (select 1 from card_deck cd where cd.card_id = c.id)
and (not exists (select 1 from card_schedule s
where s.card_id = c.id and s.user_id = @userId)
or exists (select 1 from card_schedule s
where s.card_id = c.id and s.user_id = @userId and s.due_at <= now())))::int as "DueNow",
(select count(*) from review
where user_id = @userId and reviewed_at >= now() - interval '30 days')::int as "Reviews30",
(select count(*) from review
where user_id = @userId
and reviewed_at >= now() - interval '30 days'
and rating in ('good', 'easy'))::int as "Good30",
(select coalesce(sum(duration_ms), 0) from review where user_id = @userId)::bigint as "TimeSpentMs"
""",
new { userId });
var days = (await connection.QueryAsync<DateOnly>(
"""
select distinct (reviewed_at at time zone 'UTC')::date as day
from review
where user_id = @userId
order by day desc
""",
new { userId })).ToList();
var retention = row.Reviews30 == 0 ? 0 : (int)Math.Round(row.Good30 * 100.0 / row.Reviews30);
return new StatsSummary(
row.TotalReviews,
row.ReviewsToday,
Streak(days),
row.CardsSeen,
row.DueNow,
retention,
row.TimeSpentMs);
}
public async Task<IReadOnlyList<DailyStat>> GetDailyStatsAsync(
long userId,
int days,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<DailyStat>(
"""
select day::date as "Day",
count(r.id)::int as "Total",
count(r.id) filter (where r.rating = 'again')::int as "Again",
count(r.id) filter (where r.rating = 'hard')::int as "Hard",
count(r.id) filter (where r.rating = 'good')::int as "Good",
count(r.id) filter (where r.rating = 'easy')::int as "Easy",
coalesce(sum(r.duration_ms), 0)::bigint as "DurationMs"
from generate_series((now() at time zone 'UTC')::date - (@days - 1) * interval '1 day',
(now() at time zone 'UTC')::date,
interval '1 day') day
left join review r
on r.user_id = @userId
and (r.reviewed_at at time zone 'UTC')::date = day::date
group by day
order by day
""",
new { userId, days });
return rows.ToList();
}
public async Task<IReadOnlyList<RatingStat>> GetRatingStatsAsync(
long userId,
int days,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<RatingStat>(
"""
select rating as "Rating", count(*)::int as "Count"
from review
where user_id = @userId and reviewed_at >= now() - (@days * interval '1 day')
group by rating
""",
new { userId, days });
return rows.ToList();
}
public async Task<IReadOnlyList<HardCardStat>> GetHardCardsAsync(
long userId,
int limit,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<HardCardStat>(
"""
select c.id as "CardId",
coalesce((select body from card_side where card_id = c.id and kind = 'front' limit 1), '') as "Front",
count(r.id)::int as "Reviews",
coalesce(max((select lapses from card_schedule where card_id = c.id and user_id = @userId)), 0)::int as "Lapses",
avg(case when r.rating = 'again' then 1.0 else 0.0 end)::double precision as "AgainRate"
from review r
join card c on c.id = r.card_id
where r.user_id = @userId and c.deleted_at is null
group by c.id
having count(r.id) >= 2
order by avg(case when r.rating = 'again' then 1.0 else 0.0 end) desc, count(r.id) desc
limit @limit
""",
new { userId, limit });
return rows.ToList();
}
public async Task<IReadOnlyList<DeckStat>> GetDeckStatsAsync(
long userId,
int days,
int limit,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var rows = await connection.QueryAsync<DeckStat>(
"""
select d.id as "DeckId", d.name as "DeckName", l.name as "LessonName", co.name as "CourseName",
count(r.id)::int as "Reviews",
round(100.0 * count(r.id) filter (where r.rating in ('good', 'easy')) / count(r.id))::int as "Retention"
from review r
join deck d on d.id = r.deck_id
join lesson l on l.id = d.lesson_id
join course co on co.id = l.course_id
where r.user_id = @userId and r.reviewed_at >= now() - (@days * interval '1 day')
group by d.id, l.id, co.id
order by count(r.id) desc
limit @limit
""",
new { userId, days, limit });
return rows.ToList();
}
static int Streak(IReadOnlyList<DateOnly> days)
{
if (days.Count == 0)
{
return 0;
}
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var expected = days[0] == today ? today : today.AddDays(-1);
if (days[0] != expected)
{
return 0;
}
var streak = 0;
foreach (var day in days)
{
if (day != expected)
{
break;
}
streak++;
expected = expected.AddDays(-1);
}
return streak;
}
sealed class SummaryRow
{
public int TotalReviews { get; init; }
public int ReviewsToday { get; init; }
public int CardsSeen { get; init; }
public int DueNow { get; init; }
public int Reviews30 { get; init; }
public int Good30 { get; init; }
public long TimeSpentMs { get; init; }
}
}
+161
View File
@@ -0,0 +1,161 @@
using Dapper;
using Flashsharp.Domain;
using Flashsharp.Scheduling;
namespace Flashsharp.Data;
public sealed partial class Store
{
public async Task<StudyCard?> GetNextStudyCardAsync(
long userId,
long deckId,
string mode,
IReadOnlyList<long> exclude,
CancellationToken cancellationToken = default)
{
if (mode is not ("due" or "all" or "new"))
{
throw new ArgumentException($"Unknown study mode: {mode}.", nameof(mode));
}
await using var connection = await db.OpenAsync(cancellationToken);
var row = await connection.QuerySingleOrDefaultAsync<StudyRow>(
"""
select c.id as "CardId", s.due_at as "DueAt", s.repetitions as "Repetitions", s.lapses as "Lapses",
(s.user_id is null) as "IsNew"
from card_deck cd
join card c on c.id = cd.card_id
left join card_schedule s on s.card_id = c.id and s.user_id = @userId
where cd.deck_id = @deckId
and c.deleted_at is null
and not (c.id = any(@exclude))
and (
@mode = 'all'
or (@mode = 'new' and s.user_id is null)
or (@mode = 'due' and (s.user_id is null or s.due_at <= now()))
)
order by s.due_at nulls first, c.id
limit 1
""",
new { userId, deckId, mode, exclude = exclude.ToArray() });
if (row is null)
{
return null;
}
var card = await GetCardAsync(row.CardId, cancellationToken);
if (card is null)
{
return null;
}
return new StudyCard(card, row.IsNew, row.DueAt, row.Repetitions, row.Lapses);
}
public async Task<StudyCard?> GetStudyCardAsync(
long userId,
long cardId,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
var row = await connection.QuerySingleOrDefaultAsync<StudyRow>(
"""
select c.id as "CardId", s.due_at as "DueAt", s.repetitions as "Repetitions", s.lapses as "Lapses",
(s.user_id is null) as "IsNew"
from card c
left join card_schedule s on s.card_id = c.id and s.user_id = @userId
where c.id = @cardId and c.deleted_at is null
""",
new { userId, cardId });
if (row is null)
{
return null;
}
var card = await GetCardAsync(row.CardId, cancellationToken);
return card is null ? null : new StudyCard(card, row.IsNew, row.DueAt, row.Repetitions, row.Lapses);
}
public async Task<ReviewOutcome> RecordReviewAsync(
long userId,
long cardId,
long? deckId,
ReviewRating rating,
int durationMs,
CancellationToken cancellationToken = default)
{
await using var connection = await db.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var reviewId = await connection.ExecuteScalarAsync<long>(
"""
insert into review (user_id, card_id, deck_id, rating, duration_ms)
values (@userId, @cardId, @deckId, @rating::review_rating, @durationMs)
returning id
""",
new
{
userId,
cardId,
deckId,
rating = rating.ToString().ToLowerInvariant(),
durationMs = Math.Max(0, durationMs)
},
transaction);
var current = await connection.QuerySingleOrDefaultAsync<ScheduleState>(
"""
select due_at as "DueAt", interval_seconds as "IntervalSeconds", ease as "Ease",
repetitions as "Repetitions", lapses as "Lapses"
from card_schedule
where user_id = @userId and card_id = @cardId
for update
""",
new { userId, cardId },
transaction);
var next = Scheduler.Next(current, rating, DateTime.UtcNow);
await connection.ExecuteAsync(
"""
insert into card_schedule
(user_id, card_id, due_at, interval_seconds, ease, repetitions, lapses, last_review_id, updated_at)
values
(@userId, @cardId, @dueAt, @intervalSeconds, @ease, @repetitions, @lapses, @reviewId, now())
on conflict (user_id, card_id) do update
set due_at = excluded.due_at,
interval_seconds = excluded.interval_seconds,
ease = excluded.ease,
repetitions = excluded.repetitions,
lapses = excluded.lapses,
last_review_id = excluded.last_review_id,
updated_at = now()
""",
new
{
userId,
cardId,
dueAt = next.DueAt,
intervalSeconds = next.IntervalSeconds,
ease = next.Ease,
repetitions = next.Repetitions,
lapses = next.Lapses,
reviewId
},
transaction);
await transaction.CommitAsync(cancellationToken);
return new ReviewOutcome(next.DueAt, next.IntervalSeconds, next.Ease, next.Repetitions, next.Lapses);
}
sealed class StudyRow
{
public long CardId { get; init; }
public DateTime? DueAt { get; init; }
public int Repetitions { get; init; }
public int Lapses { get; init; }
public bool IsNew { get; init; }
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Flashsharp.Data;
public sealed partial class Store
{
readonly Db db;
public Store(Db db)
{
this.db = db;
}
public static class TokenKinds
{
public const string Browser = "browser";
public const string Api = "api";
}
}