• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

felleslosninger / einnsyn-backend / 21687028034

04 Feb 2026 08:22PM UTC coverage: 83.003% (-0.4%) from 83.423%
21687028034

push

github

web-flow
Merge pull request #585 from felleslosninger/gne-code-quality-fixes

EIN-X: Github Code Quality fixes

2281 of 3179 branches covered (71.75%)

Branch coverage included in aggregate %.

7188 of 8229 relevant lines covered (87.35%)

3.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.61
src/main/java/no/einnsyn/backend/common/search/SearchQueryService.java
1
package no.einnsyn.backend.common.search;
2

3
import co.elastic.clients.elasticsearch._types.FieldValue;
4
import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
5
import co.elastic.clients.elasticsearch._types.query_dsl.Operator;
6
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
7
import co.elastic.clients.elasticsearch._types.query_dsl.RangeQuery;
8
import co.elastic.clients.elasticsearch._types.query_dsl.SimpleQueryStringFlag;
9
import co.elastic.clients.elasticsearch._types.query_dsl.SimpleQueryStringQuery;
10
import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
11
import co.elastic.clients.elasticsearch._types.query_dsl.TermsQuery;
12
import co.elastic.clients.elasticsearch._types.query_dsl.TermsQueryField;
13
import java.time.LocalDate;
14
import java.time.LocalDateTime;
15
import java.time.ZoneId;
16
import java.time.ZonedDateTime;
17
import java.time.format.DateTimeFormatter;
18
import java.time.format.DateTimeParseException;
19
import java.util.ArrayList;
20
import java.util.List;
21
import no.einnsyn.backend.authentication.AuthenticationService;
22
import no.einnsyn.backend.common.exceptions.models.BadRequestException;
23
import no.einnsyn.backend.common.exceptions.models.EInnsynException;
24
import no.einnsyn.backend.common.queryparameters.models.FilterParameters;
25
import no.einnsyn.backend.entities.enhet.EnhetService;
26
import org.springframework.stereotype.Service;
27
import org.springframework.util.StringUtils;
28

29
@Service
30
public class SearchQueryService {
31

32
  public enum DateBoundary {
3✔
33
    NONE,
6✔
34
    START_OF_DAY,
6✔
35
    END_OF_DAY
6✔
36
  }
37

38
  private static final List<String> allowedEntities =
4✔
39
      List.of("Journalpost", "Saksmappe", "Moetemappe", "Moetesak");
2✔
40
  public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
2✔
41
  private static final ZoneId NORWEGIAN_ZONE = ZoneId.of("Europe/Oslo");
4✔
42

43
  private final AuthenticationService authenticationService;
44
  private final EnhetService enhetService;
45

46
  public SearchQueryService(
47
      AuthenticationService authenticationService, EnhetService enhetService) {
2✔
48
    this.authenticationService = authenticationService;
3✔
49
    this.enhetService = enhetService;
3✔
50
  }
1✔
51

52
  private String toIsoDateTime(String dateString, DateBoundary boundary) {
53
    if (dateString == null) {
2!
54
      return null;
×
55
    }
56

57
    ZonedDateTime zonedDateTime;
58

59
    // DateTime
60
    if (dateString.contains("T")) {
4✔
61
      // Try parsing zoned first; if no zone/offset is present, assume system default zone
62
      try {
63
        zonedDateTime = ZonedDateTime.parse(dateString);
3✔
64
      } catch (DateTimeParseException e) {
×
65
        var localDateTime = LocalDateTime.parse(dateString);
×
66
        zonedDateTime = localDateTime.atZone(NORWEGIAN_ZONE);
×
67
      }
1✔
68
    }
69

70
    // Date (no timestamp)
71
    else {
72
      zonedDateTime = LocalDate.parse(dateString).atStartOfDay(NORWEGIAN_ZONE);
5✔
73
      // Adjust to start or end of day if needed
74
      zonedDateTime =
1✔
75
          switch (boundary) {
3!
76
            case START_OF_DAY -> zonedDateTime.withHour(0).withMinute(0).withSecond(0).withNano(0);
×
77
            case END_OF_DAY ->
78
                zonedDateTime.withHour(23).withMinute(59).withSecond(59).withNano(999_999_999);
10✔
79
            case NONE -> zonedDateTime;
1✔
80
          };
81
    }
82

83
    return zonedDateTime.format(FORMATTER);
4✔
84
  }
85

86
  /**
87
   * Resolve IDs from identifiers like orgnummer, email, ...
88
   *
89
   * @param enhetIdentifiers the list of identifiers to resolve
90
   * @return the list of resolved Enhet IDs
91
   * @throws BadRequestException if an Enhet is not found
92
   */
93
  private List<String> resolveEnhetIds(List<String> enhetIdentifiers) throws BadRequestException {
94
    var enhetIds = new ArrayList<String>(enhetIdentifiers.size());
6✔
95
    for (var identifier : enhetIdentifiers) {
10✔
96
      var enhetId = enhetService.resolveId(identifier);
5✔
97
      if (enhetId == null) {
2!
98
        throw new BadRequestException("Enhet not found: " + identifier);
×
99
      }
100
      enhetIds.add(enhetId);
4✔
101
    }
1✔
102
    return enhetIds;
2✔
103
  }
104

105
  /**
106
   * Adds a filter to the bool query.
107
   *
108
   * @param bqb the bool query builder
109
   * @param propertyName the name of the property to filter on
110
   * @param list the list of values to filter by
111
   */
112
  private void addFilter(BoolQuery.Builder bqb, String propertyName, List<String> list) {
113
    if (list != null && !list.isEmpty()) {
5!
114
      var fieldValueList = list.stream().map(FieldValue::of).toList();
6✔
115
      bqb.filter(
6✔
116
          TermsQuery.of(tqb -> tqb.field(propertyName).terms(tqfb -> tqfb.value(fieldValueList)))
12✔
117
              ._toQuery());
3✔
118
    }
119
  }
1✔
120

121
  /**
122
   * Adds a must-not clause to the bool query.
123
   *
124
   * @param bqb the bool query builder
125
   * @param propertyName the name of the property to exclude
126
   * @param list the list of values to exclude
127
   */
128
  private void addMustNot(BoolQuery.Builder bqb, String propertyName, List<String> list) {
129
    if (list != null && !list.isEmpty()) {
5!
130
      var fieldValueList = list.stream().map(FieldValue::of).toList();
6✔
131
      bqb.mustNot(
6✔
132
          TermsQuery.of(tqb -> tqb.field(propertyName).terms(tqfb -> tqfb.value(fieldValueList)))
12✔
133
              ._toQuery());
3✔
134
    }
135
  }
1✔
136

137
  /**
138
   * Build a ES Query from the given search parameters.
139
   *
140
   * @param filterParameters the filter parameters
141
   * @return the bool query builder
142
   * @throws EInnsynException if an error occurs
143
   */
144
  public BoolQuery.Builder getQueryBuilder(FilterParameters filterParameters)
145
      throws EInnsynException {
146
    return getQueryBuilder(filterParameters, false);
5✔
147
  }
148

149
  /**
150
   * Build a ES Query from the given search parameters.
151
   *
152
   * @param filterParameters the filter parameters
153
   * @param uncensored whether to exclude sensitive fields or not
154
   * @return the bool query builder
155
   * @throws EInnsynException if an error occurs
156
   */
157
  public BoolQuery.Builder getQueryBuilder(FilterParameters filterParameters, boolean uncensored)
158
      throws EInnsynException {
159
    var rootBoolQueryBuilder = new BoolQuery.Builder();
4✔
160

161
    // Filter by entity. We don't want unexpected entities (Innsynskrav, Downloads, ...), so we'll
162
    // always filter by entities.
163
    if (filterParameters.getEntity() != null) {
3✔
164
      addFilter(rootBoolQueryBuilder, "type", filterParameters.getEntity());
7✔
165
    } else {
166
      addFilter(rootBoolQueryBuilder, "type", allowedEntities);
5✔
167
    }
168

169
    // Exclude hidden enhets and unaccessible documents
170
    if (!uncensored) {
2✔
171
      var authenticatedEnhetId = authenticationService.getEnhetId();
4✔
172
      var authenticatedSubtreeIdList = enhetService.getSubtreeIdList(authenticatedEnhetId);
5✔
173

174
      // Filter hidden enhets that the user is not authenticated for
175
      var hiddenEnhetList = enhetService.findHidden();
4✔
176
      var hiddenIdList =
1✔
177
          hiddenEnhetList.stream()
2✔
178
              .map(e -> e.getId())
3✔
179
              .filter(e -> !authenticatedSubtreeIdList.contains(e))
1!
180
              .toList();
2✔
181
      if (!hiddenIdList.isEmpty()) {
3!
182
        addMustNot(rootBoolQueryBuilder, "administrativEnhetTransitive", hiddenIdList);
×
183
      }
184
    }
185

186
    // Exclude unaccessible documents
187
    if (!uncensored) {
2✔
188
      var authenticatedEnhetId = authenticationService.getEnhetId();
4✔
189
      var accessibleAfterBoolQueryBuilder = new BoolQuery.Builder();
4✔
190
      accessibleAfterBoolQueryBuilder.minimumShouldMatch("1");
4✔
191

192
      // Allow documents with a valid accessibleAfter
193
      accessibleAfterBoolQueryBuilder.should(
4✔
194
          RangeQuery.of(r -> r.date(d -> d.field("accessibleAfter").lte("now")))._toQuery());
15✔
195

196
      // If logged in, allow documents with a valid administrativEnhet
197
      if (authenticatedEnhetId != null) {
2✔
198
        var authenticatedEnhetFieldValues = List.of(FieldValue.of(authenticatedEnhetId));
4✔
199
        accessibleAfterBoolQueryBuilder.should(
7✔
200
            new TermsQuery.Builder()
201
                .field("administrativEnhetTransitive")
5✔
202
                .terms(new TermsQueryField.Builder().value(authenticatedEnhetFieldValues).build())
4✔
203
                .build()
1✔
204
                ._toQuery());
3✔
205
      }
206

207
      rootBoolQueryBuilder.filter(accessibleAfterBoolQueryBuilder.build()._toQuery());
8✔
208
    }
209

210
    // Filter by search query
211
    var queryString = filterParameters.getQuery();
3✔
212
    if (StringUtils.hasText(queryString)) {
3✔
213
      rootBoolQueryBuilder.must(
3✔
214
          uncensored
2✔
215
              ? getSearchStringQuery(
5✔
216
                  queryString, List.of("search_tittel^3.0", "search_tittel_SENSITIV^3.0"))
1✔
217
              : getSearchStringQuery(
5✔
218
                  queryString,
219
                  List.of("search_tittel^3.0"),
2✔
220
                  List.of("search_tittel_SENSITIV^3.0")));
1✔
221
    }
222

223
    // Filter by tittel
224
    if (filterParameters.getTittel() != null) {
3✔
225
      for (var tittel : filterParameters.getTittel()) {
11✔
226
        if (StringUtils.hasText(tittel)) {
3!
227
          rootBoolQueryBuilder.filter(
3✔
228
              uncensored
2!
229
                  ? getSearchStringQuery(tittel, List.of("search_tittel", "search_tittel_SENSITIV"))
×
230
                  : getSearchStringQuery(
5✔
231
                      tittel, List.of("search_tittel"), List.of("search_tittel_SENSITIV")));
3✔
232
        }
233
      }
1✔
234
    }
235

236
    // Filter by skjermingshjemmel
237
    if (filterParameters.getSkjermingshjemmel() != null) {
3✔
238
      for (var skjermingshjemmel : filterParameters.getSkjermingshjemmel()) {
11✔
239
        if (StringUtils.hasText(skjermingshjemmel)) {
3!
240
          rootBoolQueryBuilder.filter(
5✔
241
              getSearchStringQuery(skjermingshjemmel, List.of("skjerming.skjermingshjemmel")));
4✔
242
        }
243
      }
1✔
244
    }
245

246
    // Filter by korrespondansepartNavn
247
    if (filterParameters.getKorrespondansepartNavn() != null) {
3✔
248
      for (var korrespondansepartNavn : filterParameters.getKorrespondansepartNavn()) {
11✔
249
        if (StringUtils.hasText(korrespondansepartNavn)) {
3!
250
          rootBoolQueryBuilder.filter(
5✔
251
              getSearchStringQuery(
3✔
252
                  korrespondansepartNavn,
253
                  List.of("korrespondansepart.korrespondansepartNavn_SENSITIV"),
2✔
254
                  List.of("korrespondansepart.korrespondansepartNavn")));
1✔
255
        }
256
      }
1✔
257
    }
258

259
    // Filter by saksaar
260
    addFilter(rootBoolQueryBuilder, "saksaar", filterParameters.getSaksaar());
6✔
261

262
    // Filter by sakssekvensnummer
263
    addFilter(rootBoolQueryBuilder, "sakssekvensnummer", filterParameters.getSakssekvensnummer());
6✔
264

265
    // Filter by saksnummer
266
    addFilter(rootBoolQueryBuilder, "saksnummer", filterParameters.getSaksnummer());
6✔
267

268
    // Filter by journalpostnummer
269
    addFilter(rootBoolQueryBuilder, "journalpostnummer", filterParameters.getJournalpostnummer());
6✔
270

271
    // Filter by journalsekvensnummer
272
    addFilter(
5✔
273
        rootBoolQueryBuilder, "journalsekvensnummer", filterParameters.getJournalsekvensnummer());
1✔
274

275
    // Filter by moetesaksaar
276
    addFilter(rootBoolQueryBuilder, "møtesaksår", filterParameters.getMoetesaksaar());
6✔
277

278
    // Filter by moetesakssekvensnummer
279
    addFilter(
5✔
280
        rootBoolQueryBuilder,
281
        "møtesakssekvensnummer",
282
        filterParameters.getMoetesakssekvensnummer());
1✔
283

284
    // Matches against administrativEnhet or children
285
    if (filterParameters.getAdministrativEnhet() != null) {
3✔
286
      var enhetList = resolveEnhetIds(filterParameters.getAdministrativEnhet());
5✔
287
      addFilter(rootBoolQueryBuilder, "administrativEnhetTransitive", enhetList);
5✔
288
    }
289

290
    // Exact matches against administrativEnhet
291
    if (filterParameters.getAdministrativEnhetExact() != null) {
3✔
292
      var enhetList = resolveEnhetIds(filterParameters.getAdministrativEnhetExact());
5✔
293
      addFilter(rootBoolQueryBuilder, "administrativEnhet", enhetList);
5✔
294
    }
295

296
    // Exclude documents from given administrativEnhet or children
297
    if (filterParameters.getExcludeAdministrativEnhet() != null) {
3✔
298
      var enhetList = resolveEnhetIds(filterParameters.getExcludeAdministrativEnhet());
5✔
299
      addMustNot(rootBoolQueryBuilder, "administrativEnhetTransitive", enhetList);
5✔
300
    }
301

302
    // Exclude documents from given administrativEnhet
303
    if (filterParameters.getExcludeAdministrativEnhetExact() != null) {
3✔
304
      var enhetList = resolveEnhetIds(filterParameters.getExcludeAdministrativEnhetExact());
5✔
305
      addMustNot(rootBoolQueryBuilder, "administrativEnhet", enhetList);
5✔
306
    }
307

308
    // Filter by publisertDatoTo
309
    if (filterParameters.getPublisertDatoTo() != null) {
3✔
310
      var date = toIsoDateTime(filterParameters.getPublisertDatoTo(), DateBoundary.END_OF_DAY);
6✔
311
      rootBoolQueryBuilder.filter(
5✔
312
          RangeQuery.of(r -> r.date(d -> d.field("publisertDato").lte(date)))._toQuery());
16✔
313
    }
314

315
    // Filter by publisertDatoFrom
316
    if (filterParameters.getPublisertDatoFrom() != null) {
3✔
317
      var date = toIsoDateTime(filterParameters.getPublisertDatoFrom(), DateBoundary.NONE);
6✔
318
      rootBoolQueryBuilder.filter(
5✔
319
          RangeQuery.of(r -> r.date(d -> d.field("publisertDato").gte(date)))._toQuery());
16✔
320
    }
321

322
    // Filter by oppdatertDatoTo
323
    if (filterParameters.getOppdatertDatoTo() != null) {
3✔
324
      var date = toIsoDateTime(filterParameters.getOppdatertDatoTo(), DateBoundary.END_OF_DAY);
6✔
325
      rootBoolQueryBuilder.filter(
5✔
326
          RangeQuery.of(r -> r.date(d -> d.field("oppdatertDato").lte(date)))._toQuery());
16✔
327
    }
328

329
    // Filter by oppdatertDatoFrom
330
    if (filterParameters.getOppdatertDatoFrom() != null) {
3✔
331
      var date = toIsoDateTime(filterParameters.getOppdatertDatoFrom(), DateBoundary.NONE);
6✔
332
      rootBoolQueryBuilder.filter(
5✔
333
          RangeQuery.of(r -> r.date(d -> d.field("oppdatertDato").gte(date)))._toQuery());
16✔
334
    }
335

336
    // Filter by dokumentetsDatoTo
337
    if (filterParameters.getDokumentetsDatoTo() != null) {
3✔
338
      var date = toIsoDateTime(filterParameters.getDokumentetsDatoTo(), DateBoundary.END_OF_DAY);
6✔
339
      rootBoolQueryBuilder.filter(
5✔
340
          RangeQuery.of(r -> r.date(d -> d.field("dokumentetsDato").lte(date)))._toQuery());
16✔
341
    }
342

343
    // Filter by dokumentetsDatoFrom
344
    if (filterParameters.getDokumentetsDatoFrom() != null) {
3✔
345
      var date = toIsoDateTime(filterParameters.getDokumentetsDatoFrom(), DateBoundary.NONE);
6✔
346
      rootBoolQueryBuilder.filter(
5✔
347
          RangeQuery.of(r -> r.date(d -> d.field("dokumentetsDato").gte(date)))._toQuery());
16✔
348
    }
349

350
    // Filter by journaldatoTo
351
    if (filterParameters.getJournaldatoTo() != null) {
3✔
352
      var date = toIsoDateTime(filterParameters.getJournaldatoTo(), DateBoundary.END_OF_DAY);
6✔
353
      rootBoolQueryBuilder.filter(
5✔
354
          RangeQuery.of(r -> r.date(d -> d.field("journaldato").lte(date)))._toQuery());
16✔
355
    }
356

357
    // Filter by journaldatoFrom
358
    if (filterParameters.getJournaldatoFrom() != null) {
3✔
359
      var date = toIsoDateTime(filterParameters.getJournaldatoFrom(), DateBoundary.NONE);
6✔
360
      rootBoolQueryBuilder.filter(
5✔
361
          RangeQuery.of(r -> r.date(d -> d.field("journaldato").gte(date)))._toQuery());
16✔
362
    }
363

364
    // Filter by moetedatoTo
365
    if (filterParameters.getMoetedatoTo() != null) {
3✔
366
      var date = toIsoDateTime(filterParameters.getMoetedatoTo(), DateBoundary.END_OF_DAY);
6✔
367
      rootBoolQueryBuilder.filter(
5✔
368
          RangeQuery.of(r -> r.date(d -> d.field("moetedato").lte(date)))._toQuery());
16✔
369
    }
370

371
    // Filter by moetedatoFrom
372
    if (filterParameters.getMoetedatoFrom() != null) {
3✔
373
      var date = toIsoDateTime(filterParameters.getMoetedatoFrom(), DateBoundary.NONE);
6✔
374
      rootBoolQueryBuilder.filter(
5✔
375
          RangeQuery.of(r -> r.date(d -> d.field("moetedato").gte(date)))._toQuery());
16✔
376
    }
377

378
    // Filter by fulltext
379
    if (filterParameters.getFulltext() != null) {
3✔
380
      rootBoolQueryBuilder.filter(
5✔
381
          TermQuery.of(tqb -> tqb.field("fulltext").value(filterParameters.getFulltext()))
9✔
382
              ._toQuery());
3✔
383
    }
384

385
    // Filter by journalposttype
386
    if (filterParameters.getJournalposttype() != null) {
3✔
387
      addFilter(rootBoolQueryBuilder, "journalposttype", filterParameters.getJournalposttype());
6✔
388
    }
389

390
    // Get specific IDs
391
    addFilter(rootBoolQueryBuilder, "id", filterParameters.getIds());
6✔
392

393
    return rootBoolQueryBuilder;
2✔
394
  }
395

396
  /**
397
   * Get a sensitive query that handles uncensored/censored searches.
398
   *
399
   * @param queryString the query string to search for
400
   * @param sensitiveFields the list of sensitive fields
401
   * @param nonSensitiveFields the list of non-sensitive fields
402
   * @return the constructed query
403
   */
404
  private static Query getSearchStringQuery(
405
      String queryString, List<String> sensitiveFields, List<String> nonSensitiveFields) {
406
    var boolQueryBuilder = new BoolQuery.Builder();
4✔
407
    boolQueryBuilder.minimumShouldMatch("1");
4✔
408

409
    // Match non-sensitive fields for all documents
410
    boolQueryBuilder.should(getSearchStringQuery(queryString, nonSensitiveFields));
8✔
411

412
    // Match sensitive fields for documents from the past year only
413
    var lastYear = ZonedDateTime.now().minusYears(1).format(FORMATTER);
6✔
414
    var gteLastYear = RangeQuery.of(r -> r.date(d -> d.field("publisertDato").gte(lastYear)));
16✔
415
    var recentDocumentsQuery =
5✔
416
        new BoolQuery.Builder()
417
            .filter(q -> q.range(gteLastYear))
7✔
418
            .must(getSearchStringQuery(queryString, sensitiveFields))
4✔
419
            .build();
2✔
420
    boolQueryBuilder.should(b -> b.bool(recentDocumentsQuery));
9✔
421

422
    return boolQueryBuilder.build()._toQuery();
4✔
423
  }
424

425
  /**
426
   * Create a query for a search string on the given fields.
427
   *
428
   * @param searchString the search string
429
   * @param fields the fields to search in
430
   * @return the constructed query
431
   */
432
  private static Query getSearchStringQuery(String searchString, List<String> fields) {
433
    return SimpleQueryStringQuery.of(
5✔
434
            r ->
435
                r.query(searchString)
5✔
436
                    .fields(fields)
2✔
437
                    .defaultOperator(Operator.And)
2✔
438
                    .autoGenerateSynonymsPhraseQuery(true)
3✔
439
                    .analyzeWildcard(true)
21✔
440
                    .flags(
1✔
441
                        SimpleQueryStringFlag.Phrase, // Enable quoted phrases
442
                        SimpleQueryStringFlag.And, // Enable + operator
443
                        SimpleQueryStringFlag.Or, // Enable \| operator
444
                        SimpleQueryStringFlag.Precedence, // Enable parenthesis
445
                        SimpleQueryStringFlag.Prefix) // Enable wildcard *
446
            )
447
        ._toQuery();
1✔
448
  }
449
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc