This commit is contained in:
vfs committed 2026-05-10 10:49:20 +02:00
1 parent 136a1c34b3
commit cb87af4f3b
108 files changed
+49079 -41

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
package data
import (
"context"
"database/sql"
"time"
)
type DeviceToken struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
CreatedAt time.Time `json:"created_at"`
}
type DeviceTokenModel struct {
DB *sql.DB
}
func (m DeviceTokenModel) Insert(userID int64, token string) error {
query := `
INSERT INTO device_tokens (user_id, token)
VALUES ($1, $2)
ON CONFLICT (token) DO UPDATE SET user_id = EXCLUDED.user_id`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, userID, token)
return err
}
func (m DeviceTokenModel) GetForUser(userID int64) ([]DeviceToken, error) {
query := `SELECT id, user_id, token, created_at FROM device_tokens WHERE user_id = $1`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rows, err := m.DB.QueryContext(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var tokens []DeviceToken
for rows.Next() {
var dt DeviceToken
if err := rows.Scan(&dt.ID, &dt.UserID, &dt.Token, &dt.CreatedAt); err != nil {
return nil, err
}
tokens = append(tokens, dt)
}
return tokens, rows.Err()
}
func (m DeviceTokenModel) GetAllTokens() ([]DeviceToken, error) {
query := `SELECT id, user_id, token, created_at FROM device_tokens`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := m.DB.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var tokens []DeviceToken
for rows.Next() {
var dt DeviceToken
if err := rows.Scan(&dt.ID, &dt.UserID, &dt.Token, &dt.CreatedAt); err != nil {
return nil, err
}
tokens = append(tokens, dt)
}
return tokens, rows.Err()
}
func (m DeviceTokenModel) Delete(token string) error {
query := `DELETE FROM device_tokens WHERE token = $1`
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, token)
return err
}
+4
View File
@@ -20,6 +20,8 @@ type Models struct {
BlindSigns BlindSignModel
Votes VoteModel
Options OptionModel
ParlWatcher ParlWatcherModel
DeviceTokens DeviceTokenModel
}
func NewModels(db *sql.DB) Models {
@@ -33,5 +35,7 @@ func NewModels(db *sql.DB) Models {
BlindSigns: BlindSignModel{DB: db},
Votes: VoteModel{DB: db},
Options: OptionModel{DB: db},
ParlWatcher: ParlWatcherModel{DB: db},
DeviceTokens: DeviceTokenModel{DB: db},
}
}
+82
View File
@@ -0,0 +1,82 @@
package data
import (
"context"
"database/sql"
"time"
"github.com/lib/pq"
)
type ParlWatcherModel struct {
DB *sql.DB
}
// FilterNew returns only the numbers from the input that are not yet recorded.
func (m ParlWatcherModel) FilterNew(numbers []string) ([]string, error) {
query := `
SELECT unnest($1::text[]) AS num
EXCEPT
SELECT number FROM parl_notified_votes`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := m.DB.QueryContext(ctx, query, pq.Array(numbers))
if err != nil {
return nil, err
}
defer rows.Close()
var result []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
result = append(result, n)
}
return result, rows.Err()
}
// MarkNotified records the given numbers so they are not notified again.
func (m ParlWatcherModel) MarkNotified(numbers []string) error {
query := `
INSERT INTO parl_notified_votes (number)
SELECT unnest($1::text[])
ON CONFLICT (number) DO NOTHING`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := m.DB.ExecContext(ctx, query, pq.Array(numbers))
return err
}
// GetAllNotifiedNumbers returns every number that has already been notified.
// Unused in the hot path but useful for debugging/admin.
func (m ParlWatcherModel) GetAllNotifiedNumbers() ([]string, error) {
query := `SELECT number FROM parl_notified_votes ORDER BY notified_at DESC`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := m.DB.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var result []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
result = append(result, n)
}
return result, rows.Err()
}
// Ensure ParlWatcherModel satisfies the interface even if DB is nil (e.g. in tests).
var _ = sql.ErrNoRows
+23
View File
@@ -392,3 +392,26 @@ WHERE id = $1`
return nil
}
func (m UserModel) GetAllActivated() ([]*User, error) {
query := `SELECT id, email, name FROM users WHERE activated = true ORDER BY id`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := m.DB.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var users []*User
for rows.Next() {
u := &User{}
if err := rows.Scan(&u.ID, &u.Email, &u.Name); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
@@ -0,0 +1,55 @@
{{define "subject"}}{{.Count}} neue Parlamentsabstimmung{{if gt .Count 1}}en{{end}}{{end}}
{{define "plainBody"}}
Hallo {{.Name}},
im Nationalrat wurde{{if gt .Count 1}}n{{end}} {{.Count}} neue Abstimmung{{if gt .Count 1}}en{{end}} durchgeführt:
{{range .Docs}}
• {{.Date}} — {{.Title}} ({{.Number}})
{{if .VoteText}}{{.VoteText}}{{end}}
{{end}}
Details unter: https://party.at/parlament
Die Digitale Partei
{{end}}
{{define "htmlBody"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<style>
body { font-family: sans-serif; font-size: 15px; color: #111; background: #f9f9f9; margin: 0; padding: 0; }
.wrap { max-width: 560px; margin: 40px auto; background: #fff; border-radius: 8px; border: 1px solid #e5e7eb; padding: 32px; }
h2 { margin: 0 0 20px; font-size: 20px; }
.vote { border: 1px solid #e5e7eb; border-radius: 6px; padding: 14px 16px; margin-bottom: 12px; }
.vote-title { font-weight: 600; margin-bottom: 4px; }
.vote-meta { font-size: 13px; color: #6b7280; margin-bottom: 6px; }
.vote-result { font-size: 13px; }
.footer { margin-top: 28px; font-size: 13px; color: #6b7280; }
a { color: #2563eb; }
</style>
</head>
<body>
<div class="wrap">
<h2>{{.Count}} neue Parlamentsabstimmung{{if gt .Count 1}}en{{end}}</h2>
<p>Hallo {{.Name}},</p>
<p>im Nationalrat wurde{{if gt .Count 1}}n{{end}} soeben folgende Abstimmung{{if gt .Count 1}}en{{end}} durchgeführt:</p>
{{range .Docs}}
<div class="vote">
<div class="vote-title">{{.Title}}</div>
<div class="vote-meta">{{.Date}} &middot; {{.Number}}</div>
{{if .VoteText}}<div class="vote-result">{{.VoteText}}</div>{{end}}
</div>
{{end}}
<p><a href="https://party.at/parlament">Alle Parlamentsabstimmungen ansehen &rarr;</a></p>
<div class="footer">Die Digitale Partei</div>
</div>
</body>
</html>
{{end}}