@valentinkolb/cloud 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,287 @@
1
+ import { redis } from "bun";
2
+ import { coreSettings } from "../settings/api";
3
+ import { logger } from "../logging";
4
+ import type { CurrentWeather, DailyForecast, HourlyForecast, WeatherData, WeatherIcon } from "./types";
5
+
6
+ const log = logger("weather");
7
+
8
+ const BRIGHTSKY_API = "https://api.brightsky.dev";
9
+
10
+ export type ForecastLocationConfig = {
11
+ lat?: string;
12
+ lon?: string;
13
+ };
14
+
15
+ /** Brightsky current_weather API response */
16
+ type BrightskyCurrentResponse = {
17
+ weather: {
18
+ timestamp: string;
19
+ source_id: number;
20
+ cloud_cover: number | null;
21
+ condition: string | null;
22
+ dew_point: number | null;
23
+ icon: WeatherIcon | null;
24
+ precipitation_10: number | null;
25
+ precipitation_30: number | null;
26
+ precipitation_60: number | null;
27
+ pressure_msl: number | null;
28
+ relative_humidity: number | null;
29
+ solar_10: number | null;
30
+ solar_30: number | null;
31
+ solar_60: number | null;
32
+ sunshine_30: number | null;
33
+ sunshine_60: number | null;
34
+ temperature: number | null;
35
+ visibility: number | null;
36
+ wind_direction_10: number | null;
37
+ wind_direction_30: number | null;
38
+ wind_direction_60: number | null;
39
+ wind_gust_direction_10: number | null;
40
+ wind_gust_direction_30: number | null;
41
+ wind_gust_direction_60: number | null;
42
+ wind_gust_speed_10: number | null;
43
+ wind_gust_speed_30: number | null;
44
+ wind_gust_speed_60: number | null;
45
+ wind_speed_10: number | null;
46
+ wind_speed_30: number | null;
47
+ wind_speed_60: number | null;
48
+ };
49
+ sources: Array<{
50
+ id: number;
51
+ dwd_station_id: string;
52
+ station_name: string;
53
+ lat: number;
54
+ lon: number;
55
+ height: number;
56
+ distance: number;
57
+ }>;
58
+ };
59
+
60
+ /** Brightsky weather (forecast) API response */
61
+ type BrightskyWeatherResponse = {
62
+ weather: Array<{
63
+ timestamp: string;
64
+ temperature: number | null;
65
+ icon: WeatherIcon | null;
66
+ precipitation: number | null;
67
+ precipitation_probability: number | null;
68
+ wind_speed: number | null;
69
+ cloud_cover: number | null;
70
+ sunshine: number | null;
71
+ }>;
72
+ sources: Array<{
73
+ station_name: string;
74
+ }>;
75
+ };
76
+
77
+ /** Get cache key for location (default or custom). */
78
+ const getCacheKey = (lat: string, lon: string): string => {
79
+ return `weather:${lat}:${lon}`;
80
+ };
81
+
82
+ /** Fetch current weather from Brightsky API. */
83
+ const fetchCurrentFromApi = async (lat: string, lon: string): Promise<CurrentWeather | null> => {
84
+ try {
85
+ const url = `${BRIGHTSKY_API}/current_weather?lat=${lat}&lon=${lon}`;
86
+ const response = await fetch(url);
87
+
88
+ if (!response.ok) {
89
+ log.error("Brightsky API error", { status: response.status });
90
+ return null;
91
+ }
92
+
93
+ const data: BrightskyCurrentResponse = await response.json();
94
+
95
+ if (!data.weather || data.weather.temperature === null) {
96
+ log.error("Invalid Brightsky response");
97
+ return null;
98
+ }
99
+
100
+ const w = data.weather;
101
+ const source = data.sources?.[0];
102
+
103
+ return {
104
+ temperature: Math.round(w.temperature!),
105
+ icon: w.icon ?? "cloudy",
106
+ cloudCover: w.cloud_cover ?? 0,
107
+ windSpeed: Math.round(w.wind_speed_10 ?? w.wind_speed_30 ?? 0),
108
+ windGust: w.wind_gust_speed_10 ? Math.round(w.wind_gust_speed_10) : null,
109
+ windDirection: w.wind_direction_10 ?? w.wind_direction_30 ?? null,
110
+ humidity: w.relative_humidity,
111
+ precipitation: w.precipitation_60 ?? 0,
112
+ pressure: w.pressure_msl != null ? Math.round(w.pressure_msl) : null,
113
+ visibility: w.visibility != null ? Math.round(w.visibility) : null,
114
+ dewPoint: w.dew_point != null ? Math.round(w.dew_point * 10) / 10 : null,
115
+ sunshine: w.sunshine_60 != null ? w.sunshine_60 : null,
116
+ stationName: source?.station_name ?? "Unknown",
117
+ timestamp: w.timestamp,
118
+ };
119
+ } catch (error) {
120
+ log.error("Failed to fetch from Brightsky", {
121
+ error: error instanceof Error ? error.message : String(error),
122
+ });
123
+ return null;
124
+ }
125
+ };
126
+
127
+ /** Get current weather, using Redis cache. */
128
+ export const getCurrentWeather = async (config?: ForecastLocationConfig): Promise<CurrentWeather | null> => {
129
+ const lat = config?.lat ?? (await coreSettings.get<string>("weather.default_lat"));
130
+ const lon = config?.lon ?? (await coreSettings.get<string>("weather.default_lon"));
131
+ const cacheKey = getCacheKey(lat, lon);
132
+
133
+ const cached = await redis.get(cacheKey);
134
+ if (cached) {
135
+ try {
136
+ return JSON.parse(cached) as CurrentWeather;
137
+ } catch {
138
+ // Invalid cache, will refetch
139
+ }
140
+ }
141
+
142
+ const result = await fetchCurrentFromApi(lat, lon);
143
+ if (!result) return null;
144
+
145
+ const ttl = Math.round(((await coreSettings.get<number>("weather.cache_minutes")) ?? 30) * 60);
146
+ await redis.set(cacheKey, JSON.stringify(result), "EX", ttl);
147
+
148
+ return result;
149
+ };
150
+
151
+ /** Get most common icon from array. */
152
+ const getMostCommonIcon = (icons: WeatherIcon[]): WeatherIcon => {
153
+ if (icons.length === 0) return "cloudy";
154
+ const counts = new Map<WeatherIcon, number>();
155
+ for (const icon of icons) {
156
+ counts.set(icon, (counts.get(icon) ?? 0) + 1);
157
+ }
158
+ let maxCount = 0;
159
+ let result: WeatherIcon = "cloudy";
160
+ for (const [icon, count] of counts) {
161
+ if (count > maxCount) {
162
+ maxCount = count;
163
+ result = icon;
164
+ }
165
+ }
166
+ return result;
167
+ };
168
+
169
+ /** Fetch weather forecast from Brightsky API. */
170
+ const fetchForecastFromApi = async (lat: string, lon: string): Promise<{ hourly: HourlyForecast[]; daily: DailyForecast[] } | null> => {
171
+ try {
172
+ const now = new Date();
173
+ const endDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
174
+
175
+ const dateStr = now.toISOString().split("T")[0];
176
+ const endDateStr = endDate.toISOString().split("T")[0];
177
+
178
+ const url = `${BRIGHTSKY_API}/weather?lat=${lat}&lon=${lon}&date=${dateStr}&last_date=${endDateStr}`;
179
+ const response = await fetch(url);
180
+
181
+ if (!response.ok) {
182
+ log.error("Brightsky forecast API error", { status: response.status });
183
+ return null;
184
+ }
185
+
186
+ const data: BrightskyWeatherResponse = await response.json();
187
+
188
+ if (!data.weather || data.weather.length === 0) {
189
+ return null;
190
+ }
191
+
192
+ const hourly: HourlyForecast[] = data.weather
193
+ .filter((w) => w.temperature !== null && new Date(w.timestamp) >= now)
194
+ .slice(0, 12)
195
+ .map((w) => ({
196
+ timestamp: w.timestamp,
197
+ temperature: Math.round(w.temperature ?? 0),
198
+ icon: w.icon ?? "cloudy",
199
+ precipitation: w.precipitation ?? 0,
200
+ precipitationProbability: w.precipitation_probability,
201
+ windSpeed: Math.round(w.wind_speed ?? 0),
202
+ cloudCover: w.cloud_cover ?? 0,
203
+ }));
204
+
205
+ const dailyMap = new Map<
206
+ string,
207
+ {
208
+ temps: number[];
209
+ icons: WeatherIcon[];
210
+ precip: number;
211
+ precipProb: number[];
212
+ sunshine: number;
213
+ }
214
+ >();
215
+
216
+ for (const w of data.weather) {
217
+ if (w.temperature === null) continue;
218
+ const date = w.timestamp.split("T")[0];
219
+ if (!date) continue;
220
+ const existing = dailyMap.get(date) ?? {
221
+ temps: [],
222
+ icons: [],
223
+ precip: 0,
224
+ precipProb: [],
225
+ sunshine: 0,
226
+ };
227
+ existing.temps.push(w.temperature);
228
+ if (w.icon) existing.icons.push(w.icon);
229
+ existing.precip += w.precipitation ?? 0;
230
+ if (w.precipitation_probability != null) {
231
+ existing.precipProb.push(w.precipitation_probability);
232
+ }
233
+ existing.sunshine += w.sunshine ?? 0;
234
+ dailyMap.set(date, existing);
235
+ }
236
+
237
+ const daily: DailyForecast[] = Array.from(dailyMap.entries())
238
+ .slice(0, 7)
239
+ .map(([date, day]) => ({
240
+ date,
241
+ tempMin: Math.round(Math.min(...day.temps)),
242
+ tempMax: Math.round(Math.max(...day.temps)),
243
+ icon: getMostCommonIcon(day.icons),
244
+ precipitation: Math.round(day.precip * 10) / 10,
245
+ precipitationProbability: day.precipProb.length > 0 ? Math.max(...day.precipProb) : null,
246
+ sunshine: Math.round(day.sunshine),
247
+ }));
248
+
249
+ return { hourly, daily };
250
+ } catch (error) {
251
+ log.error("Failed to fetch forecast", {
252
+ error: error instanceof Error ? error.message : String(error),
253
+ });
254
+ return null;
255
+ }
256
+ };
257
+
258
+ /** Get full weather data including forecasts. */
259
+ export const getWeatherData = async (config?: ForecastLocationConfig): Promise<WeatherData | null> => {
260
+ const lat = config?.lat ?? (await coreSettings.get<string>("weather.default_lat"));
261
+ const lon = config?.lon ?? (await coreSettings.get<string>("weather.default_lon"));
262
+ const cacheKey = `weather:full:${lat}:${lon}`;
263
+
264
+ const cached = await redis.get(cacheKey);
265
+ if (cached) {
266
+ try {
267
+ return JSON.parse(cached) as WeatherData;
268
+ } catch {
269
+ // Invalid cache, will refetch
270
+ }
271
+ }
272
+
273
+ const [current, forecast] = await Promise.all([getCurrentWeather(config), fetchForecastFromApi(lat, lon)]);
274
+
275
+ if (!current) return null;
276
+
277
+ const result: WeatherData = {
278
+ current,
279
+ hourly: forecast?.hourly ?? [],
280
+ daily: forecast?.daily ?? [],
281
+ };
282
+
283
+ const ttl = Math.round(((await coreSettings.get<number>("weather.cache_minutes")) ?? 30) * 60);
284
+ await redis.set(cacheKey, JSON.stringify(result), "EX", ttl);
285
+
286
+ return result;
287
+ };
@@ -0,0 +1,110 @@
1
+ import * as settings from "../settings";
2
+ import { err, fail, ok, type PageParams, type Paginated, type Result } from "@valentinkolb/stdlib";
3
+ import { geoService, type GeoPlace } from "../../server";
4
+
5
+ export const WEATHER_COUNTRY_CODE = "DE";
6
+
7
+ export type WeatherCity = {
8
+ name: string;
9
+ lat: number;
10
+ lon: number;
11
+ country?: string;
12
+ state?: string;
13
+ };
14
+
15
+ /**
16
+ * Adapts generic geo place data to the weather app's city model.
17
+ */
18
+ const mapPlace = (place: GeoPlace): WeatherCity => ({
19
+ name: place.name,
20
+ lat: place.lat,
21
+ lon: place.lon,
22
+ country: place.country,
23
+ state: place.state,
24
+ });
25
+
26
+ /**
27
+ * Resolves and validates the configured geo service base URL.
28
+ */
29
+ const getGeoBaseUrl = async (): Promise<Result<string>> => {
30
+ const geoUrl = (await settings.get<string>("weather.geo_url")).trim();
31
+ if (!geoUrl) {
32
+ return fail(err.internal("Geo API URL is not configured. Set weather.geo_url."));
33
+ }
34
+ return ok(geoUrl.replace(/\/$/, ""));
35
+ };
36
+
37
+ /**
38
+ * Searches city candidates via the geo backend and enforces weather-specific country constraints.
39
+ */
40
+ const list = async (config: {
41
+ pagination?: PageParams;
42
+ filter: {
43
+ query: string;
44
+ country?: string;
45
+ };
46
+ }): Promise<Result<Paginated<WeatherCity>>> => {
47
+ const query = config.filter.query.trim();
48
+ if (!query) {
49
+ return ok({
50
+ items: [],
51
+ page: config.pagination?.page ?? 1,
52
+ perPage: config.pagination?.perPage ?? 20,
53
+ total: 0,
54
+ hasNext: false,
55
+ });
56
+ }
57
+
58
+ const requestedCountry = config.filter.country?.trim().toUpperCase() ?? WEATHER_COUNTRY_CODE;
59
+ if (requestedCountry !== WEATHER_COUNTRY_CODE) {
60
+ return fail(err.badInput("Only German city search is supported (country=DE)."));
61
+ }
62
+
63
+ const baseUrlResult = await getGeoBaseUrl();
64
+ if (!baseUrlResult.ok) return baseUrlResult;
65
+ const geoUrl = baseUrlResult.data;
66
+ const result = await geoService.place.list({
67
+ baseUrl: geoUrl,
68
+ pagination: config.pagination,
69
+ filter: {
70
+ query,
71
+ country: WEATHER_COUNTRY_CODE,
72
+ featureClass: "P",
73
+ },
74
+ });
75
+ if (!result.ok) return result;
76
+
77
+ return ok({
78
+ ...result.data,
79
+ items: result.data.items.map(mapPlace),
80
+ });
81
+ };
82
+
83
+ /**
84
+ * Resolves one place by coordinates via the geo backend.
85
+ */
86
+ const get = async (config: { lat: number; lon: number }): Promise<Result<WeatherCity | null>> => {
87
+ const baseUrlResult = await getGeoBaseUrl();
88
+ if (!baseUrlResult.ok) return baseUrlResult;
89
+ const result = await geoService.place.get({
90
+ baseUrl: baseUrlResult.data,
91
+ lat: config.lat,
92
+ lon: config.lon,
93
+ });
94
+ if (!result.ok) return result;
95
+
96
+ const place = result.data;
97
+ if (!place) return ok(null);
98
+ if (place.featureClass !== undefined && place.featureClass !== "P") {
99
+ return ok(null);
100
+ }
101
+
102
+ return ok(mapPlace(place));
103
+ };
104
+
105
+ export const weatherCityService = {
106
+ list,
107
+ get,
108
+ };
109
+
110
+ export type WeatherCityService = typeof weatherCityService;
@@ -0,0 +1,99 @@
1
+ import { registerGroupLabel, registerSettings } from "../settings/defaults";
2
+ import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
3
+ import { getCurrentWeather, getWeatherData } from "./forecast";
4
+ import { WEATHER_COUNTRY_CODE } from "./geo";
5
+ import { weatherLocationService } from "./location";
6
+ import { weatherLocationsService } from "./locations";
7
+ import type { WeatherData } from "./types";
8
+ import { weatherUiService } from "./ui";
9
+
10
+ registerGroupLabel("weather", "Weather");
11
+ registerSettings([
12
+ {
13
+ key: "weather.default_lat",
14
+ kind: "string",
15
+ default: "",
16
+ description: "Default latitude",
17
+ placeholder: "e.g. 48.401082 (Ulm)",
18
+ group: "weather",
19
+ },
20
+ {
21
+ key: "weather.default_lon",
22
+ kind: "string",
23
+ default: "",
24
+ description: "Default longitude",
25
+ placeholder: "e.g. 9.987608 (Ulm)",
26
+ group: "weather",
27
+ },
28
+ {
29
+ key: "weather.cache_minutes",
30
+ kind: "number",
31
+ default: 30,
32
+ description: "How long weather data is cached before fetching fresh data (in minutes)",
33
+ group: "weather",
34
+ },
35
+ {
36
+ key: "weather.geo_url",
37
+ kind: "url",
38
+ default: "",
39
+ description: "Geocoding API URL for the location search feature",
40
+ placeholder: "e.g. https://geocoding.example.com/search",
41
+ group: "weather",
42
+ },
43
+ ]);
44
+
45
+ /**
46
+ * Resolves a city name to coordinates and returns weather for the first match.
47
+ */
48
+ const getByCityName = async (config: { query: string }): Promise<Result<WeatherData>> => {
49
+ const query = config.query.trim();
50
+ if (!query) {
51
+ return fail(err.badInput("City query is required"));
52
+ }
53
+
54
+ const cityResult = await weatherLocationService.city.list({
55
+ pagination: { page: 1, perPage: 1 },
56
+ filter: {
57
+ query,
58
+ country: WEATHER_COUNTRY_CODE,
59
+ },
60
+ });
61
+ if (!cityResult.ok) return cityResult;
62
+
63
+ const city = cityResult.data.items[0];
64
+ if (!city) {
65
+ return fail(err.notFound("City"));
66
+ }
67
+
68
+ const weather = await getWeatherData({
69
+ lat: String(city.lat),
70
+ lon: String(city.lon),
71
+ });
72
+ if (!weather) {
73
+ return fail(err.notFound("Weather data for city"));
74
+ }
75
+
76
+ return ok(weather);
77
+ };
78
+
79
+ export const weatherService = {
80
+ forecast: {
81
+ get: getWeatherData,
82
+ current: {
83
+ get: getCurrentWeather,
84
+ },
85
+ getByCityName,
86
+ },
87
+ location: weatherLocationService,
88
+ locations: weatherLocationsService,
89
+ ui: weatherUiService,
90
+ };
91
+
92
+ export type WeatherService = typeof weatherService;
93
+ export type {
94
+ WeatherData,
95
+ DailyForecast,
96
+ CurrentWeather,
97
+ HourlyForecast,
98
+ WeatherIcon,
99
+ } from "./types";
@@ -0,0 +1,24 @@
1
+ import { weatherLocationsService } from "./locations";
2
+ import { weatherCityService } from "./geo";
3
+
4
+ /** Weather location cookie name. */
5
+ export const WEATHER_LOCATION_COOKIE = "weather_location";
6
+
7
+ /** Parse weather location from cookie value. */
8
+ export const parseLocationCookie = (cookieValue: string | undefined): { lat: string; lon: string } | undefined => {
9
+ if (!cookieValue) return undefined;
10
+ const [lat, lon] = cookieValue.split(",");
11
+ if (!lat || !lon) return undefined;
12
+ return { lat, lon };
13
+ };
14
+
15
+ export const weatherLocationService = {
16
+ saved: weatherLocationsService,
17
+ city: weatherCityService,
18
+ cookie: {
19
+ name: WEATHER_LOCATION_COOKIE,
20
+ parse: parseLocationCookie,
21
+ },
22
+ };
23
+
24
+ export type WeatherLocationService = typeof weatherLocationService;
@@ -0,0 +1,125 @@
1
+ import { sql } from "bun";
2
+ import { err, fail, ok, paginate, type PageParams, type Paginated, type Result } from "@valentinkolb/stdlib";
3
+ import { logger } from "../logging";
4
+
5
+ const log = logger("weather");
6
+
7
+ export type Location = {
8
+ id: string;
9
+ name: string;
10
+ state: string | null;
11
+ lat: number;
12
+ lon: number;
13
+ };
14
+
15
+ /**
16
+ * Stores one user-owned weather location and returns the persisted row.
17
+ */
18
+ const create = async (config: {
19
+ userId: string;
20
+ data: {
21
+ name: string;
22
+ state?: string;
23
+ lat: number;
24
+ lon: number;
25
+ };
26
+ }): Promise<Result<Location>> => {
27
+ try {
28
+ const [location] = await sql`
29
+ INSERT INTO weather_locations (user_id, name, state, lat, lon)
30
+ VALUES (${config.userId}, ${config.data.name}, ${config.data.state ?? null}, ${config.data.lat}, ${config.data.lon})
31
+ RETURNING id, name, state, lat, lon
32
+ `;
33
+ return ok(location as Location);
34
+ } catch (error) {
35
+ log.error("Failed to create location", {
36
+ error: error instanceof Error ? error.message : String(error),
37
+ });
38
+ return fail(err.internal("Failed to create location"));
39
+ }
40
+ };
41
+
42
+ /**
43
+ * Deletes one saved location owned by the user and reports `NOT_FOUND` when no row matches.
44
+ */
45
+ const remove = async (config: { id: string; userId: string }): Promise<Result<void>> => {
46
+ try {
47
+ const result = await sql`
48
+ DELETE FROM weather_locations
49
+ WHERE id = ${config.id}::uuid AND user_id = ${config.userId}
50
+ RETURNING id
51
+ `;
52
+
53
+ if (result.length === 0) {
54
+ return fail(err.notFound("Location"));
55
+ }
56
+
57
+ return ok();
58
+ } catch (error) {
59
+ log.error("Failed to delete location", {
60
+ error: error instanceof Error ? error.message : String(error),
61
+ });
62
+ return fail(err.internal("Failed to delete location"));
63
+ }
64
+ };
65
+
66
+ /**
67
+ * Lists all saved weather locations for one user with optional search and pagination.
68
+ */
69
+ const list = async (config: { userId: string; pagination?: PageParams; filter?: { query?: string } }): Promise<Paginated<Location>> => {
70
+ const locations = (await sql`
71
+ SELECT id, name, state, lat, lon
72
+ FROM weather_locations
73
+ WHERE user_id = ${config.userId}
74
+ ORDER BY created_at ASC
75
+ `) as Location[];
76
+
77
+ const query = config.filter?.query?.trim().toLowerCase();
78
+ const filtered =
79
+ query && query.length > 0
80
+ ? locations.filter((location) => {
81
+ const name = location.name.toLowerCase();
82
+ const state = (location.state ?? "").toLowerCase();
83
+ return name.includes(query) || state.includes(query);
84
+ })
85
+ : locations;
86
+
87
+ if (!config.pagination) {
88
+ return {
89
+ items: filtered,
90
+ page: 1,
91
+ perPage: filtered.length,
92
+ total: filtered.length,
93
+ hasNext: false,
94
+ };
95
+ }
96
+
97
+ const { page, perPage, offset } = paginate(config.pagination);
98
+ const items = filtered.slice(offset, offset + perPage);
99
+ return {
100
+ items,
101
+ page,
102
+ perPage,
103
+ total: filtered.length,
104
+ hasNext: page * perPage < filtered.length,
105
+ };
106
+ };
107
+
108
+ /**
109
+ * Returns one saved location for the owning user, or `null` if it is missing/inaccessible.
110
+ */
111
+ const get = async (config: { id: string; userId: string }): Promise<Location | null> => {
112
+ const [location] = await sql`
113
+ SELECT id, name, state, lat, lon
114
+ FROM weather_locations
115
+ WHERE id = ${config.id}::uuid AND user_id = ${config.userId}
116
+ `;
117
+ return (location as Location) ?? null;
118
+ };
119
+
120
+ export const weatherLocationsService = {
121
+ list,
122
+ get,
123
+ create,
124
+ remove,
125
+ };
@@ -0,0 +1,22 @@
1
+ import { sql } from "bun";
2
+
3
+ export const migrate = async (): Promise<void> => {
4
+ await sql`
5
+ CREATE TABLE IF NOT EXISTS weather_locations (
6
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
8
+ name TEXT NOT NULL,
9
+ state TEXT,
10
+ lat DOUBLE PRECISION NOT NULL,
11
+ lon DOUBLE PRECISION NOT NULL,
12
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
13
+ )
14
+ `.simple();
15
+ console.log(" ✓ weather_locations table");
16
+
17
+ await sql`
18
+ CREATE INDEX IF NOT EXISTS idx_weather_locations_user
19
+ ON weather_locations(user_id)
20
+ `.simple();
21
+ console.log(" ✓ weather_locations index");
22
+ };