Build flashsharp flashcard app
This commit is contained in:
commit
c97759b04c
69 files changed
+6959
No files matched your search
@@ -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();
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user